"""Tests for SME-Hub incubation services (SP2).

Coverage targets the key PRD acceptance points:
* FRSME-INC004 / INC006 — programme cannot publish without a complete rubric
  or without an application deadline.
* FRSME-INC008 — eligibility blocks unverified businesses, mismatched sector,
  and post-deadline applications.
* FRSME-INC010 / INC013 — auto-save persists between requests; submission
  flips status from DRAFT to SUBMITTED and clears the auto-save buffer.
* FRSME-INC020 — weighted-average scoring math (per-section average,
  normalised by max marks, multiplied by section weight).
* FRSME-INC021 — judge reminder selection skips judges who already scored.
* FRSME-INC024 / INC025 — REP-enrolment failures don't block other members.
* FRSME-INC033 — withdrawing preserves data; status flips.
* FRSME-INC034 — certificate generation is idempotent.
"""
from __future__ import annotations

from datetime import timedelta
from decimal import Decimal

import pytest
from django.contrib.auth import get_user_model
from django.utils import timezone

from apps.core.permissions.roles import UserRole
from apps.smehub.incubation import services
from apps.smehub.incubation.models import (
    Application,
    Cohort,
    CohortMember,
    JudgeAssignment,
    JudgeScore,
    Programme,
    ProgressRecord,
    RubricSection,
    ScoringRubric,
)
from apps.smehub.onboarding.models import (
    Business,
    BusinessBaseline,
    BusinessStage,
    EntrepreneurProfile,
    RevenueRange,
)

User = get_user_model()
pytestmark = pytest.mark.django_db


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def officer():
    return User.objects.create_user(
        email="officer@example.com",
        password="x",
        role=UserRole.PROGRAMME_OFFICER,
    )


@pytest.fixture
def admin():
    return User.objects.create_user(
        email="admin@example.com",
        password="x",
        is_staff=True,
        is_superuser=True,
        role=UserRole.SYSTEM_ADMIN,
    )


@pytest.fixture
def entrepreneur():
    user = User.objects.create_user(
        email="ent@example.com", password="x", role=UserRole.ENTREPRENEUR,
    )
    profile = EntrepreneurProfile.objects.create(
        user=user, country="UG", organisation="Test Co", phone="+256000",
    )
    profile.verify(by_user=user)
    profile.save()
    return profile


@pytest.fixture
def verified_business(entrepreneur, admin):
    biz = Business.objects.create(
        entrepreneur=entrepreneur,
        name="Test Biz",
        sector="Agriculture",
        sub_sector="Maize",
        business_stage=BusinessStage.MVP,
        country="UG",
    )
    baseline = BusinessBaseline.objects.create(
        business=biz,
        business_stage_at_entry=BusinessStage.MVP,
        fte_count=2,
        pte_count=1,
        revenue_range=RevenueRange.UNDER_1K,
        primary_target_market="Local",
        geographic_reach="UG",
    )
    biz.verify(by_user=admin)
    biz.save()
    # SP6 (FRSME-MEI002) — start_application requires an SMEHubTrackingRecord;
    # emit the baseline-initialised signal so the tracking record is created.
    from apps.smehub.onboarding import signals as onb_signals

    onb_signals.smehub_baseline_initialised.send(
        sender=BusinessBaseline,
        business=biz,
        baseline=baseline,
        entrepreneur=entrepreneur,
    )
    return biz


@pytest.fixture
def programme_draft(officer):
    return services.create_programme(
        officer=officer,
        title="Test Programme",
        slug="test-programme",
        objectives="Boost agritech founders.",
        target_sectors=["Agriculture"],
        target_stages=["mvp", "early_revenue"],
        cohort_size=10,
        duration_weeks=12,
        application_deadline=timezone.now() + timedelta(days=30),
    )


@pytest.fixture
def programme_published(programme_draft, officer):
    # Build a complete rubric (sums to 1.0)
    services.add_rubric_section(programme_draft, title="A", weight=Decimal("0.5"), max_marks=10)
    services.add_rubric_section(programme_draft, title="B", weight=Decimal("0.5"), max_marks=10)
    # FRSME-INC002 — publication requires at least one assigned judge.
    roster_judge = User.objects.create_user(
        email="fixture-roster-judge@example.com", password="x", role=UserRole.JUDGE,
    )
    programme_draft.judges.set([roster_judge])
    services.publish_programme(programme_draft, by_user=officer)
    return Programme.objects.get(pk=programme_draft.pk)


def _score_application_fully(application, programme, officer, judges=None):
    """Helper used by cohort/withdrawal/certificate tests so they satisfy the
    Phase 3 INC020 strict-scoring gate without each test re-implementing the
    judge + section + score boilerplate."""
    if judges is None:
        judges = [
            User.objects.create_user(
                email=f"helper-judge-{application.pk}-{i}@example.com",
                password="x", role=UserRole.JUDGE,
            )
            for i in range(2)
        ]
    services.assign_judges(application, judges=judges, by_user=officer)
    sections = list(programme.rubric.sections.all())
    for judge in judges:
        services.submit_score(
            application,
            judge=judge,
            section_scores={s.pk: 8 for s in sections},
        )
    return judges


def _submit(application):
    """FRSME-INC012 requires business_description/problem_statement to be
    present before submission; fill them in for tests that don't otherwise
    care about pitch content."""
    application.business_description = "Test Biz supplies inputs to smallholder farmers."
    application.problem_statement = "Smallholders lack reliable access to quality inputs."
    application.save()
    services.submit_application(application)


# ---------------------------------------------------------------------------
# Programme publish gating  (FRSME-INC004 / INC006)
# ---------------------------------------------------------------------------

def test_publish_blocks_without_rubric(programme_draft, officer):
    with pytest.raises(services.ProgrammeNotPublishable):
        services.publish_programme(programme_draft, by_user=officer)


def test_publish_blocks_when_rubric_weights_dont_sum_to_one(programme_draft, officer):
    services.add_rubric_section(programme_draft, title="A", weight=Decimal("0.3"), max_marks=10)
    services.add_rubric_section(programme_draft, title="B", weight=Decimal("0.4"), max_marks=10)
    with pytest.raises(services.ProgrammeNotPublishable):
        services.publish_programme(programme_draft, by_user=officer)


def test_publish_blocks_without_deadline(officer):
    p = services.create_programme(
        officer=officer,
        title="No Deadline Prog",
        slug="no-deadline",
        cohort_size=5,
        duration_weeks=8,
    )
    services.add_rubric_section(p, title="A", weight=Decimal("1.0"), max_marks=10)
    with pytest.raises(services.ProgrammeNotPublishable):
        services.publish_programme(p, by_user=officer)


def test_publish_succeeds_when_complete(programme_published):
    assert programme_published.status == Programme.Status.PUBLISHED
    assert programme_published.published_at is not None


# ---------------------------------------------------------------------------
# Eligibility checks  (FRSME-INC008)
# ---------------------------------------------------------------------------

def test_eligibility_rejects_unverified_business(programme_published, entrepreneur, admin):
    biz = Business.objects.create(
        entrepreneur=entrepreneur,
        name="Unverified",
        sector="Agriculture",
        business_stage=BusinessStage.MVP,
    )
    BusinessBaseline.objects.create(
        business=biz, business_stage_at_entry=BusinessStage.MVP, fte_count=1, pte_count=0,
        revenue_range=RevenueRange.NONE,
    )
    ok, reason = services.check_eligibility(biz, programme_published)
    assert not ok
    assert "verified" in reason.lower()


def test_eligibility_rejects_wrong_sector(programme_published, entrepreneur, admin):
    biz = Business.objects.create(
        entrepreneur=entrepreneur,
        name="Off-sector",
        sector="Energy",  # not in target_sectors
        business_stage=BusinessStage.MVP,
    )
    BusinessBaseline.objects.create(
        business=biz, business_stage_at_entry=BusinessStage.MVP, fte_count=1, pte_count=0,
        revenue_range=RevenueRange.NONE,
    )
    biz.verify(by_user=admin)
    biz.save()
    ok, reason = services.check_eligibility(biz, programme_published)
    assert not ok
    assert "sector" in reason.lower()


def test_eligibility_rejects_after_deadline(programme_draft, officer, verified_business):
    services.add_rubric_section(programme_draft, title="A", weight=Decimal("1.0"), max_marks=10)
    programme_draft.application_deadline = timezone.now() + timedelta(days=30)
    programme_draft.save()
    programme_draft.judges.set([  # FRSME-INC002 — publication needs ≥1 judge.
        User.objects.create_user(email="deadline-test-judge@example.com", password="x", role=UserRole.JUDGE),
    ])
    services.publish_programme(programme_draft, by_user=officer)
    # Move the deadline into the past
    programme_draft.application_deadline = timezone.now() - timedelta(hours=1)
    programme_draft.save()
    ok, reason = services.check_eligibility(verified_business, programme_draft)
    assert not ok
    assert "deadline" in reason.lower()


# ---------------------------------------------------------------------------
# Application auto-save + submit  (FRSME-INC010 / INC013)
# ---------------------------------------------------------------------------

def test_auto_save_then_submit(programme_published, verified_business, entrepreneur):
    app = services.start_application(
        entrepreneur=entrepreneur, business=verified_business, programme=programme_published,
    )
    services.auto_save_application(app, payload={
        "business_description": "We grow maize.",
        "problem_statement": "Smallholders need inputs.",
        "traction": "$2k MRR.",
        "funding_needs": "$50k seed.",
        "support_sought": "Mentorship.",
    })
    fresh = Application.objects.get(pk=app.pk)
    assert fresh.business_description == "We grow maize."
    assert fresh.auto_saved_payload  # buffer present

    services.submit_application(fresh)
    after = Application.objects.get(pk=app.pk)
    assert after.status == Application.Status.SUBMITTED
    assert after.submitted_at is not None
    assert after.auto_saved_payload == {}  # cleared on submit


def test_start_application_idempotent(programme_published, verified_business, entrepreneur):
    a = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    b = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    assert a.pk == b.pk


# ---------------------------------------------------------------------------
# Weighted scoring math  (FRSME-INC020)
# ---------------------------------------------------------------------------

def test_weighted_average_normalises_by_max_marks_and_weights(programme_published, verified_business, entrepreneur, officer):
    app = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    _submit(app)
    judge1 = User.objects.create_user(email="j1@example.com", password="x", role=UserRole.JUDGE)
    judge2 = User.objects.create_user(email="j2@example.com", password="x", role=UserRole.JUDGE)
    services.assign_judges(app, judges=[judge1, judge2], by_user=officer)
    sections = list(programme_published.rubric.sections.all())
    # Each section weight = 0.5, max_marks = 10
    # Judge 1: 8 / 10 + 6 / 10 → averaged later
    # Judge 2: 6 / 10 + 4 / 10
    services.submit_score(app, judge=judge1, section_scores={sections[0].pk: 8, sections[1].pk: 6})
    services.submit_score(app, judge=judge2, section_scores={sections[0].pk: 6, sections[1].pk: 4})
    # Section A avg = 7.0 / 10 → 0.7 * 0.5 weight = 0.35
    # Section B avg = 5.0 / 10 → 0.5 * 0.5 weight = 0.25
    # Total = 0.60 → 60.00 percent
    score = services.compute_weighted_average(app)
    assert score == Decimal("60.00")


def test_weighted_average_zero_when_no_scores(programme_published, verified_business, entrepreneur):
    app = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    score = services.compute_weighted_average(app)
    assert score == Decimal("0")


# ---------------------------------------------------------------------------
# Judge reminder selection  (FRSME-INC021)
# ---------------------------------------------------------------------------

def test_remind_skips_judges_who_already_scored(programme_published, verified_business, entrepreneur, officer):
    app = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    _submit(app)
    judge1 = User.objects.create_user(email="j1@example.com", password="x", role=UserRole.JUDGE)
    judge2 = User.objects.create_user(email="j2@example.com", password="x", role=UserRole.JUDGE)
    services.assign_judges(app, judges=[judge1, judge2], by_user=officer)
    # Backdate the assignments so they're past the cutoff window
    JudgeAssignment.objects.filter(application=app).update(
        notified_at=timezone.now() - timedelta(days=10),
    )
    sections = list(programme_published.rubric.sections.all())
    services.submit_score(app, judge=judge1, section_scores={sections[0].pk: 7, sections[1].pk: 7})
    sent = services.remind_pending_judges(days_pending=3)
    # Only judge2 should receive a reminder; judge1 has already scored.
    assert sent == 1


# ---------------------------------------------------------------------------
# FRSME-INC017 — judge-roster auto-assignment on first-level review
# ---------------------------------------------------------------------------

def test_first_level_review_auto_assigns_programme_roster(
    programme_published, verified_business, entrepreneur, officer,
):
    from apps.core.notifications.models import Notification

    roster_judge = User.objects.create_user(
        email="roster-judge@example.com", password="x", role=UserRole.JUDGE,
    )
    programme_published.judges.set([roster_judge])

    app = services.start_application(
        entrepreneur=entrepreneur, business=verified_business, programme=programme_published,
    )
    _submit(app)
    services.pass_first_level_review(app, officer=officer)

    assert JudgeAssignment.objects.filter(application=app, judge=roster_judge).exists()
    assert Notification.objects.filter(
        recipient=roster_judge, verb=Notification.Verb.SMEHUB_JUDGE_ASSIGNED,
    ).exists()
    assert Application.objects.get(pk=app.pk).status == Application.Status.UNDER_REVIEW


# ---------------------------------------------------------------------------
# FRSME-INC021 — deadline-relative reminder cadence
# ---------------------------------------------------------------------------

def test_reminder_deadline_relative_window(
    programme_published, verified_business, entrepreneur, officer,
):
    judge = User.objects.create_user(email="dl-judge@example.com", password="x", role=UserRole.JUDGE)
    app = services.start_application(
        entrepreneur=entrepreneur, business=verified_business, programme=programme_published,
    )
    _submit(app)
    services.assign_judges(app, judges=[judge], by_user=officer)

    # Deadline far in the future, reminder window = 2 days before → too early.
    programme_published.scoring_deadline = timezone.now() + timedelta(days=10)
    programme_published.reminder_days_before = 2
    programme_published.save()
    assert services.remind_pending_judges() == 0

    # Move the deadline to tomorrow → now inside the 2-day window → fires.
    programme_published.scoring_deadline = timezone.now() + timedelta(days=1)
    programme_published.save()
    JudgeAssignment.objects.filter(application=app).update(last_reminded_at=None)
    assert services.remind_pending_judges() == 1
    # Throttled to once/day — a second immediate run sends nothing.
    assert services.remind_pending_judges() == 0


# ---------------------------------------------------------------------------
# Cohort confirmation + REP enrolment failure handling
# ---------------------------------------------------------------------------

def test_confirm_cohort_accepts_and_rejects(programme_published, verified_business, entrepreneur, officer, admin):
    # Accepted application
    app1 = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    _submit(app1)

    # Rejected application — different business
    biz2 = Business.objects.create(
        entrepreneur=entrepreneur,
        name="Second Biz",
        sector="Agriculture",
        business_stage=BusinessStage.MVP,
        country="UG",
    )
    BusinessBaseline.objects.create(
        business=biz2, business_stage_at_entry=BusinessStage.MVP, fte_count=1, pte_count=0,
        revenue_range=RevenueRange.NONE,
    )
    biz2.verify(by_user=admin)
    biz2.save()
    app2 = services.start_application(entrepreneur=entrepreneur, business=biz2, programme=programme_published)
    _submit(app2)

    # FRSME-INC020 — fully score the accepted application before cohort
    # confirmation; rejected apps (app2) do not need scores.
    _score_application_fully(app1, programme_published, officer)

    cohort = services.confirm_cohort(
        programme_published,
        accepted_application_ids=[app1.pk],
        name="C1",
        start_date=timezone.now().date() + timedelta(days=14),
        end_date=timezone.now().date() + timedelta(days=98),
        by_user=officer,
    )
    assert cohort.members.count() == 1
    accepted = Application.objects.get(pk=app1.pk)
    rejected = Application.objects.get(pk=app2.pk)
    assert accepted.status == Application.Status.ACCEPTED
    assert rejected.status == Application.Status.REJECTED


# ---------------------------------------------------------------------------
# Cohort member withdrawal preserves data  (FRSME-INC033)
# ---------------------------------------------------------------------------

def test_withdraw_member_preserves_data(programme_published, verified_business, entrepreneur, officer):
    app = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    _submit(app)
    _score_application_fully(app, programme_published, officer)
    cohort = services.confirm_cohort(
        programme_published,
        accepted_application_ids=[app.pk],
        name="C1",
        start_date=timezone.now().date(),
        end_date=timezone.now().date() + timedelta(days=84),
        by_user=officer,
    )
    member = cohort.members.first()
    services.withdraw_cohort_member(member, by_user=officer, reason="Personal reasons")
    fresh = CohortMember.objects.get(pk=member.pk)
    assert fresh.status == CohortMember.Status.WITHDRAWN
    assert fresh.withdrawn_reason == "Personal reasons"
    # Application + business + progress row are intact
    assert Application.objects.filter(pk=app.pk).exists()
    assert ProgressRecord.objects.filter(cohort_member=fresh).exists()


# ---------------------------------------------------------------------------
# Certificate idempotency  (FRSME-INC034)
# ---------------------------------------------------------------------------

def test_certificate_generation_idempotent(programme_published, verified_business, entrepreneur, officer):
    app = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    _submit(app)
    _score_application_fully(app, programme_published, officer)
    cohort = services.confirm_cohort(
        programme_published,
        accepted_application_ids=[app.pk],
        name="C1",
        start_date=timezone.now().date(),
        end_date=timezone.now().date() + timedelta(days=84),
        by_user=officer,
    )
    member = cohort.members.first()
    member.mark_complete(manual=True, notes="ok")
    member.save()
    cert1 = services.generate_certificate(member, issued_by=officer)
    cert2 = services.generate_certificate(member, issued_by=officer)
    assert cert1.pk == cert2.pk


# ---------------------------------------------------------------------------
# Auto-completion when all required courses done  (receiver path)
# ---------------------------------------------------------------------------

def test_evaluate_completion_no_required_courses_no_op(programme_published, verified_business, entrepreneur, officer):
    """A programme with no course links should never auto-complete."""
    app = services.start_application(entrepreneur=entrepreneur, business=verified_business, programme=programme_published)
    _submit(app)
    _score_application_fully(app, programme_published, officer)
    cohort = services.confirm_cohort(
        programme_published,
        accepted_application_ids=[app.pk],
        name="C1",
        start_date=timezone.now().date(),
        end_date=timezone.now().date() + timedelta(days=84),
        by_user=officer,
    )
    member = cohort.members.first()
    flipped = services.evaluate_completion_criteria(member)
    assert flipped is False
    assert CohortMember.objects.get(pk=member.pk).status == CohortMember.Status.ACTIVE


# ---------------------------------------------------------------------------
# FRSME-INC033 — full completion criteria (min sessions + attendance + config)
# ---------------------------------------------------------------------------

def _make_active_member(programme, verified_business, entrepreneur, officer, name="C1"):
    app = services.start_application(
        entrepreneur=entrepreneur, business=verified_business, programme=programme,
    )
    _submit(app)
    _score_application_fully(app, programme, officer)
    cohort = services.confirm_cohort(
        programme,
        accepted_application_ids=[app.pk],
        name=name,
        start_date=timezone.now().date(),
        end_date=timezone.now().date() + timedelta(days=84),
        by_user=officer,
    )
    return cohort.members.first()


def test_completion_requires_min_mentorship_sessions(
    programme_published, verified_business, entrepreneur, officer,
):
    programme_published.min_mentorship_sessions = 2
    programme_published.save()
    member = _make_active_member(programme_published, verified_business, entrepreneur, officer)

    record = ProgressRecord.objects.get(cohort_member=member)
    record.sessions_completed = 1
    record.save()
    # One session short → not complete (re-fetch so progress reads fresh, as a
    # real request would).
    member = CohortMember.objects.get(pk=member.pk)
    assert services.evaluate_completion_criteria(member) is False
    assert CohortMember.objects.get(pk=member.pk).status == CohortMember.Status.ACTIVE

    breakdown = services.completion_criteria_breakdown(member)
    assert breakdown["configured"] is True
    assert breakdown["all_satisfied"] is False
    session_crit = next(c for c in breakdown["criteria"] if c["key"] == "sessions")
    assert session_crit["satisfied"] is False

    # Meeting the threshold flips the member and issues a certificate.
    record.sessions_completed = 2
    record.save()
    member = CohortMember.objects.get(pk=member.pk)
    assert services.evaluate_completion_criteria(member) is True
    fresh = CohortMember.objects.get(pk=member.pk)
    assert fresh.status == CohortMember.Status.PROGRAMME_COMPLETE
    from apps.smehub.incubation.models import Certificate

    assert Certificate.objects.filter(cohort_member=fresh).exists()


def test_completion_breakdown_lists_only_configured_criteria(
    programme_published, verified_business, entrepreneur, officer,
):
    # No required courses, no min sessions, no attendance threshold configured.
    member = _make_active_member(programme_published, verified_business, entrepreneur, officer)
    breakdown = services.completion_criteria_breakdown(member)
    assert breakdown["configured"] is False
    assert breakdown["criteria"] == []
    # An unconfigured programme never auto-completes.
    assert services.evaluate_completion_criteria(member) is False


def test_completion_attendance_threshold_gate(
    programme_published, verified_business, entrepreneur, officer,
):
    from apps.smehub.incubation.models import (
        MentorAssignment,
        MentorSession,
    )

    programme_published.attendance_threshold_pct = 80
    programme_published.save()
    member = _make_active_member(programme_published, verified_business, entrepreneur, officer)
    mentor = User.objects.create_user(
        email="attend-mentor@example.com", password="x", role=UserRole.JUDGE,
    )
    assignment = MentorAssignment.objects.create(cohort_member=member, mentor=mentor)
    # 1 completed + 1 cancelled = 50% attendance → below the 80% gate.
    MentorSession.objects.create(
        assignment=assignment, starts_at=timezone.now() - timedelta(days=2),
        status=MentorSession.Status.COMPLETED,
    )
    MentorSession.objects.create(
        assignment=assignment, starts_at=timezone.now() - timedelta(days=1),
        status=MentorSession.Status.CANCELLED,
    )
    assert services.evaluate_completion_criteria(member) is False
    breakdown = services.completion_criteria_breakdown(member)
    att = next(c for c in breakdown["criteria"] if c["key"] == "attendance")
    assert att["satisfied"] is False
