"""SME-Hub incubation models — Subprocess SP2.

Implements PRD Tables 37–38 (FRSME-INC001 → FRSME-INC037):
  • Programme + ScoringRubric + RubricSection (FRSME-INC001–006)
  • ProgrammeCourseLink — Moodle course link for cohort progress (FRSME-INC024)
  • Application + ApplicationDocument (FRSME-INC008–016)
  • JudgeAssignment + JudgeScore (FRSME-INC017–021)
  • Cohort + CohortMember (FRSME-INC022–023, FRSME-INC033)
  • MentorAssignment + MentorSession (FRSME-INC026–030)
  • Certificate (FRSME-INC034–035)
  • ProgressRecord (FRSME-INC031, FRSME-INC036)

FSMs live on Programme, Application and CohortMember.  Direct status
assignment is blocked by `protected=True` — call the @transition method,
then save (matches the SP1 convention; see project memory FSM gotchas).
"""
from __future__ import annotations

import uuid
from decimal import Decimal

from django.conf import settings
from django.db import models
from django.utils import timezone
from django_fsm import FSMField, transition

from apps.core.storage.paths import (
    upload_to_smehub_certificates,
    upload_to_smehub_incubation,
)
from apps.smehub.onboarding.models import Business, EntrepreneurProfile


# ---------------------------------------------------------------------------
# Programme  (FRSME-INC001 → INC006)
# ---------------------------------------------------------------------------

class Programme(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"
        CLOSED = "closed", "Closed"

    class SelectionMethod(models.TextChoices):
        WEIGHTED_RUBRIC = "weighted_rubric", "Weighted rubric scoring"
        OFFICER_DECISION = "officer_decision", "Programme officer decision"

    class CallType(models.TextChoices):
        PROGRAMME = "programme", "Programme"
        COMPETITION = "competition", "Competition"
        EXTERNAL = "external", "External call"

    title = models.CharField(max_length=255)
    slug = models.SlugField(max_length=120, unique=True)
    objectives = models.TextField(blank=True)
    excerpt = models.TextField(blank=True, help_text="Short teaser.")
    # Call type. PROGRAMME = authored in IILMP; COMPETITION carries a dynamic
    # application form (+ judged scores); EXTERNAL is a link-out announcement.
    call_type = models.CharField(
        max_length=16, choices=CallType.choices, default=CallType.PROGRAMME, db_index=True
    )
    external_url = models.URLField(max_length=500, blank=True)
    application_form_schema = models.JSONField(
        null=True, blank=True,
        help_text="jQuery-formBuilder schema for a competition's application form.",
    )
    # Bilingual (EN/FR).
    title_fr = models.CharField(max_length=255, blank=True)
    excerpt_fr = models.TextField(blank=True)
    description_fr = models.TextField(blank=True)
    external_ref = models.PositiveBigIntegerField(
        null=True, blank=True, unique=True, db_index=True,
        help_text="Import reference id from the SME-Hub `calls` table.",
    )
    target_sectors = models.JSONField(default=list, blank=True, help_text="Sector tags eligible for application.")
    target_stages = models.JSONField(default=list, blank=True, help_text="Business stage codes eligible.")
    eligibility_countries = models.JSONField(default=list, blank=True, help_text="ISO-2 codes; empty = all.")
    cohort_size = models.PositiveIntegerField(default=20)
    duration_weeks = models.PositiveIntegerField(default=12)
    aih_locations = models.JSONField(default=list, blank=True, help_text="Free-form list of AIH / venue names.")
    # FRSME-INC033 — programme-completion criteria. A member is auto-flipped to
    # PROGRAMME_COMPLETE only when every *configured* criterion is satisfied:
    # required E-Learning courses complete, at least this many mentorship
    # sessions completed, and session attendance at or above this threshold.
    # Zero means "not a gating criterion" (backwards-compatible default).
    min_mentorship_sessions = models.PositiveIntegerField(
        default=0,
        help_text="Minimum completed mentorship sessions required to finish. 0 = not required.",
    )
    attendance_threshold_pct = models.PositiveSmallIntegerField(
        default=0,
        help_text="Minimum session-attendance percentage (0–100) required to finish. 0 = not required.",
    )
    # FRSME-INC010/012 — labels of supporting documents an applicant MUST attach
    # before their application can be submitted (e.g. "Business registration",
    # "Financial statements"). Empty = no mandatory documents.
    required_document_labels = models.JSONField(
        default=list,
        blank=True,
        help_text="Supporting documents applicants must attach before submitting.",
    )
    # FRSME-INC017 — the judges configured on the programme. Applications that
    # pass first-level review are auto-assigned to this roster.
    judges = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="smehub_judge_programmes",
        help_text="Judges auto-assigned to applications that pass first-level review.",
    )
    # FRSME-INC021 — deadline-relative reminder cadence. Reminders fire
    # ``reminder_days_before`` days ahead of ``scoring_deadline`` for judges
    # who have not yet submitted a score.
    scoring_deadline = models.DateTimeField(null=True, blank=True)
    reminder_days_before = models.PositiveSmallIntegerField(
        default=2,
        help_text="Days before the scoring deadline to start reminding judges who haven't scored.",
    )
    application_opens_at = models.DateTimeField(null=True, blank=True)
    application_deadline = models.DateTimeField(null=True, blank=True)
    selection_method = models.CharField(
        max_length=32,
        choices=SelectionMethod.choices,
        default=SelectionMethod.WEIGHTED_RUBRIC,
    )
    status = FSMField(
        max_length=16,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="smehub_programmes_created",
    )
    published_at = models.DateTimeField(null=True, blank=True)
    closed_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [models.Index(fields=["status", "application_deadline"])]

    def __str__(self) -> str:
        return self.title

    @property
    def is_accepting_applications(self) -> bool:
        """Published and not past its deadline — mirrors check_eligibility."""
        if self.status != self.Status.PUBLISHED:
            return False
        if self.application_deadline and self.application_deadline < timezone.now():
            return False
        return True

    # FSM transitions ----------------------------------------------------

    @transition(field=status, source=Status.DRAFT, target=Status.PUBLISHED)
    def publish(self) -> None:
        """FRSME-INC006. Caller is expected to verify rubric completeness first."""
        self.published_at = timezone.now()

    @transition(field=status, source=Status.PUBLISHED, target=Status.CLOSED)
    def close(self) -> None:
        self.closed_at = timezone.now()

    @transition(field=status, source=Status.PUBLISHED, target=Status.DRAFT)
    def revert_to_draft(self) -> None:
        """Officers may unpublish before the deadline if the call needs revision."""
        self.published_at = None


class ScoringRubric(models.Model):
    """One rubric per programme. Sections sum to weight 1.0 (normalised).

    Mandatory before publication (FRSME-INC004).
    """

    programme = models.OneToOneField(
        Programme,
        on_delete=models.CASCADE,
        related_name="rubric",
    )
    description = models.TextField(blank=True)
    # Competition scoring guide (`guides` table): pass mark + the marked
    # form schema that drives RubricSection derivation during import.
    pass_mark = models.PositiveIntegerField(null=True, blank=True)
    application_form_schema = models.JSONField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self) -> str:
        return f"Rubric — {self.programme.title}"

    @property
    def total_weight(self) -> Decimal:
        return self.sections.aggregate(total=models.Sum("weight"))["total"] or Decimal("0")

    @property
    def is_complete(self) -> bool:
        return self.sections.exists() and abs(self.total_weight - Decimal("1")) < Decimal("0.001")


class RubricSection(models.Model):
    rubric = models.ForeignKey(ScoringRubric, on_delete=models.CASCADE, related_name="sections")
    title = models.CharField(max_length=255)
    instructions = models.TextField(blank=True)
    weight = models.DecimalField(
        max_digits=5,
        decimal_places=4,
        default=Decimal("0.25"),
        help_text="Fractional weight (sections must sum to 1.0).",
    )
    max_marks = models.PositiveSmallIntegerField(default=10)
    order = models.PositiveSmallIntegerField(default=0)
    # Guide provenance: the formBuilder field name (`text-<ts>`) this
    # section was derived from, so the `results.data` scores can be exploded
    # back onto the right section on re-import.
    form_field_name = models.CharField(max_length=128, blank=True, db_index=True)
    external_ref = models.PositiveBigIntegerField(null=True, blank=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["order", "id"]

    def __str__(self) -> str:
        return self.title


class ProgrammeCourseLink(models.Model):
    """Maps a programme to one or more Moodle courses whose completion counts
    toward cohort progress (FRSME-INC005, FRSME-INC024).

    Formerly a FK into the REP LMS (``rep_courses.Course``); the REP platform was
    retired in favour of Moodle, so a course is now referenced by its Moodle
    course id (with a denormalised title for display). Actual cohort enrolment is
    delegated to Moodle (a Moodle Web Services enrol call is future work).
    """

    programme = models.ForeignKey(
        Programme,
        on_delete=models.CASCADE,
        related_name="course_links",
    )
    moodle_course_id = models.CharField(
        max_length=64,
        help_text="Moodle course id (mdl_course.id).",
    )
    course_title = models.CharField(
        max_length=255,
        blank=True,
        help_text="Denormalised Moodle course name for display.",
    )
    is_required = models.BooleanField(default=True)
    notes = models.CharField(max_length=255, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["programme", "moodle_course_id"],
                name="uniq_smehub_programme_course",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.programme.title} → {self.course_title or self.moodle_course_id}"


# ---------------------------------------------------------------------------
# Application  (FRSME-INC008 → INC016)
# ---------------------------------------------------------------------------

class Application(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft (auto-saved)"
        SUBMITTED = "submitted", "Submitted"
        UNDER_REVIEW = "under_review", "Under review"
        RETURNED_FOR_INFO = "returned_for_info", "Returned for more info"
        ACCEPTED = "accepted", "Accepted"
        REJECTED = "rejected", "Not selected"
        WITHDRAWN = "withdrawn", "Withdrawn"

    programme = models.ForeignKey(Programme, on_delete=models.CASCADE, related_name="applications")
    entrepreneur = models.ForeignKey(EntrepreneurProfile, on_delete=models.PROTECT, related_name="smehub_applications")
    # Nullable to accommodate competition applications, which predate the
    # Business entity (the applicant filled a dynamic form, often without a
    # registered business). Programme applications always set it.
    business = models.ForeignKey(
        Business, null=True, blank=True, on_delete=models.PROTECT, related_name="smehub_applications"
    )
    application_id = models.CharField(
        max_length=20,
        unique=True,
        editable=False,
        help_text="Auto-generated public identifier.",
    )
    status = FSMField(
        max_length=24,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    business_description = models.TextField(blank=True)
    problem_statement = models.TextField(blank=True)
    traction = models.TextField(blank=True)
    funding_needs = models.TextField(blank=True)
    support_sought = models.TextField(blank=True)
    auto_saved_payload = models.JSONField(default=dict, blank=True, help_text="HTMX auto-save buffer.")
    # Dynamic competition-form payloads. The structured fields above are
    # best-effort filled by label heuristics; the full answer set is always
    # retained here so nothing is lost.
    form_responses = models.JSONField(
        null=True, blank=True, help_text="Raw {field_name: answer} as submitted.",
    )
    form_answers = models.JSONField(
        null=True, blank=True, help_text="{question_label: answer} resolved via the call form schema.",
    )
    external_ref = models.PositiveBigIntegerField(
        null=True, blank=True, unique=True, db_index=True,
        help_text="Import reference id from the SME-Hub `applications` table.",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)
    decided_at = models.DateTimeField(null=True, blank=True)
    decision_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="smehub_applications_decided",
    )
    return_message = models.TextField(blank=True)
    # FRSME-INC023 — reason recorded when an application is not selected during
    # cohort confirmation; surfaced to the applicant in the rejection notice.
    rejection_reason = models.TextField(blank=True)
    weighted_score = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
    # FRSME-INC017–021 — cached score so the selection dashboard does not
    # re-run compute_weighted_average per application on every render.
    # Kept in sync by the JudgeScore post-save/post-delete signal.
    computed_score = models.DecimalField(
        max_digits=6, decimal_places=2, null=True, blank=True, db_index=True
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["programme", "business"],
                condition=models.Q(status__in=["draft", "submitted", "under_review", "returned_for_info"]),
                name="uniq_smehub_active_application_per_business",
            ),
        ]
        indexes = [
            models.Index(fields=["programme", "status"]),
            models.Index(fields=["entrepreneur", "status"]),
        ]

    def __str__(self) -> str:
        return f"{self.application_id} — {self.business.name} → {self.programme.title}"

    def save(self, *args, **kwargs):
        if not self.application_id:
            self.application_id = f"INC-{uuid.uuid4().hex[:8].upper()}"
        super().save(*args, **kwargs)

    # FSM transitions ----------------------------------------------------

    @transition(field=status, source=Status.DRAFT, target=Status.SUBMITTED)
    def submit(self) -> None:
        self.submitted_at = timezone.now()
        self.auto_saved_payload = {}

    @transition(field=status, source=Status.SUBMITTED, target=Status.UNDER_REVIEW)
    def begin_review(self) -> None:
        pass

    @transition(
        field=status,
        source=[Status.SUBMITTED, Status.UNDER_REVIEW],
        target=Status.RETURNED_FOR_INFO,
    )
    def return_for_info(self, *, message: str) -> None:
        self.return_message = message

    @transition(field=status, source=Status.RETURNED_FOR_INFO, target=Status.SUBMITTED)
    def resubmit(self) -> None:
        self.return_message = ""
        self.submitted_at = timezone.now()

    @transition(
        field=status,
        source=[Status.SUBMITTED, Status.UNDER_REVIEW],
        target=Status.ACCEPTED,
    )
    def accept(self, *, by_user, weighted_score=None) -> None:
        self.decision_by = by_user
        self.decided_at = timezone.now()
        if weighted_score is not None:
            self.weighted_score = weighted_score

    @transition(
        field=status,
        source=[Status.SUBMITTED, Status.UNDER_REVIEW],
        target=Status.REJECTED,
    )
    def reject(self, *, by_user, weighted_score=None, reason: str = "") -> None:
        self.decision_by = by_user
        self.decided_at = timezone.now()
        if weighted_score is not None:
            self.weighted_score = weighted_score
        if reason:
            self.rejection_reason = reason

    @transition(
        field=status,
        source=[Status.DRAFT, Status.SUBMITTED, Status.UNDER_REVIEW, Status.RETURNED_FOR_INFO],
        target=Status.WITHDRAWN,
    )
    def withdraw(self) -> None:
        pass


class ApplicationDocument(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="documents")
    label = models.CharField(max_length=255)
    file = models.FileField(upload_to=upload_to_smehub_incubation)
    uploaded_at = models.DateTimeField(auto_now_add=True)

    def __str__(self) -> str:
        return f"{self.application.application_id}: {self.label}"


# ---------------------------------------------------------------------------
# Judging  (FRSME-INC017 → INC021)
# ---------------------------------------------------------------------------

class JudgeAssignment(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="judge_assignments")
    judge = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name="smehub_judge_assignments",
    )
    notified_at = models.DateTimeField(null=True, blank=True)
    last_reminded_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["application", "judge"],
                name="uniq_smehub_judge_per_application",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.judge.email} ⇢ {self.application.application_id}"


class JudgeScore(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="scores")
    judge = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name="smehub_judge_scores",
    )
    section = models.ForeignKey(RubricSection, on_delete=models.PROTECT, related_name="scores")
    score = models.DecimalField(max_digits=5, decimal_places=2)
    comments = models.TextField(blank=True)
    submitted_at = models.DateTimeField(default=timezone.now)
    # Per-question score map stored once per (application, judge) for audit;
    # individual sections are exploded into the rows above.
    external_ref = models.PositiveBigIntegerField(
        null=True, blank=True, db_index=True,
        help_text="Import reference id from the SME-Hub `results` row this score came from.",
    )
    score_breakdown = models.JSONField(
        null=True, blank=True, help_text="Full {field_name: score} payload.",
    )

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["application", "judge", "section"],
                name="uniq_smehub_score_per_judge_section",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.application.application_id}/{self.section.title}: {self.score}"


# ---------------------------------------------------------------------------
# Cohort + CohortMember  (FRSME-INC022 → INC023, INC033)
# ---------------------------------------------------------------------------

class Cohort(models.Model):
    programme = models.ForeignKey(Programme, on_delete=models.CASCADE, related_name="cohorts")
    name = models.CharField(max_length=255)
    start_date = models.DateField()
    end_date = models.DateField()
    venue = models.CharField(max_length=255, blank=True)
    schedule = models.JSONField(default=dict, blank=True, help_text="Free-form schedule payload.")
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-start_date"]

    def __str__(self) -> str:
        return f"{self.programme.title} — {self.name}"


class CohortMember(models.Model):
    class Status(models.TextChoices):
        ACTIVE = "active", "Active"
        WITHDRAWN = "withdrawn", "Withdrawn"
        PROGRAMME_COMPLETE = "programme_complete", "Programme complete"

    cohort = models.ForeignKey(Cohort, on_delete=models.CASCADE, related_name="members")
    application = models.OneToOneField(
        Application,
        on_delete=models.PROTECT,
        related_name="cohort_membership",
    )
    entrepreneur = models.ForeignKey(EntrepreneurProfile, on_delete=models.PROTECT, related_name="cohort_memberships")
    business = models.ForeignKey(Business, on_delete=models.PROTECT, related_name="cohort_memberships")
    status = FSMField(
        max_length=24,
        choices=Status.choices,
        default=Status.ACTIVE,
        protected=True,
        db_index=True,
    )
    withdrawn_reason = models.TextField(blank=True)
    completed_at = models.DateTimeField(null=True, blank=True)
    completed_manually = models.BooleanField(default=False)
    completion_notes = models.TextField(blank=True)
    # FRSME-INC025 — silently-absorbed REP enrolment failures now surface.
    # Updated by enrol_cohort_in_courses / retry_cohort_enrolment in services.
    last_enrol_error = models.TextField(
        blank=True,
        help_text="Last REP enrolment failure for this member, if any.",
    )
    last_enrol_attempt_at = models.DateTimeField(null=True, blank=True)
    enrol_attempts = models.PositiveSmallIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["cohort", "status"]),
            models.Index(fields=["last_enrol_attempt_at"]),
        ]

    def __str__(self) -> str:
        return f"{self.cohort.name}: {self.business.name}"

    # FSM transitions ----------------------------------------------------

    @transition(field=status, source=Status.ACTIVE, target=Status.WITHDRAWN)
    def withdraw(self, *, reason: str) -> None:
        """FRSME-INC033 acceptance: data is preserved; status flips."""
        self.withdrawn_reason = reason

    @transition(field=status, source=Status.ACTIVE, target=Status.PROGRAMME_COMPLETE)
    def mark_complete(self, *, manual: bool = False, notes: str = "") -> None:
        self.completed_at = timezone.now()
        self.completed_manually = manual
        self.completion_notes = notes


# ---------------------------------------------------------------------------
# Mentorship  (FRSME-INC026 → INC030)
# ---------------------------------------------------------------------------

class MentorAssignment(models.Model):
    cohort_member = models.ForeignKey(CohortMember, on_delete=models.CASCADE, related_name="mentor_assignments")
    mentor = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name="smehub_mentor_assignments",
    )
    assigned_at = models.DateTimeField(default=timezone.now)
    ended_at = models.DateTimeField(null=True, blank=True)
    reassigned_from = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="reassignments",
        help_text="Set when this assignment supersedes a previous one (FRSME-INC027).",
    )
    notes = models.TextField(blank=True)

    class Meta:
        ordering = ["-assigned_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["cohort_member", "mentor"],
                condition=models.Q(ended_at__isnull=True),
                name="uniq_smehub_active_mentor_assignment",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.mentor.email} ⇢ {self.cohort_member.business.name}"

    @property
    def is_active(self) -> bool:
        return self.ended_at is None


class MentorSession(models.Model):
    class Type(models.TextChoices):
        VIRTUAL = "virtual", "Virtual"
        PHYSICAL = "physical", "Physical"

    class Status(models.TextChoices):
        SCHEDULED = "scheduled", "Scheduled"
        COMPLETED = "completed", "Completed"
        CANCELLED = "cancelled", "Cancelled"

    assignment = models.ForeignKey(MentorAssignment, on_delete=models.CASCADE, related_name="sessions")
    type = models.CharField(max_length=16, choices=Type.choices, default=Type.VIRTUAL)
    starts_at = models.DateTimeField()
    duration_minutes = models.PositiveSmallIntegerField(default=60)
    location = models.CharField(max_length=255, blank=True)
    agenda = models.TextField(blank=True)
    notes = models.TextField(blank=True)
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.SCHEDULED,
        db_index=True,
    )
    completed_at = models.DateTimeField(null=True, blank=True)
    cancellation_reason = models.CharField(max_length=255, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["starts_at"]

    def __str__(self) -> str:
        return f"Session {self.starts_at:%Y-%m-%d %H:%M} — {self.assignment.mentor.email}"


# ---------------------------------------------------------------------------
# Mentorship Readiness Assessment  (PRD §5.X.X — Mentorship and Industry Advisory)
# ---------------------------------------------------------------------------

class ReadinessAssessment(models.Model):
    """Structured readiness assessment for an incubated business.

    Captures three dimensions called out in the PRD: market positioning,
    technical robustness, and regulatory compliance. The mentor (or admin)
    scores each dimension 1–5 and surfaces gaps; the service layer
    aggregates these into a per-cohort readiness rollup.
    """

    cohort_member = models.ForeignKey(
        CohortMember,
        on_delete=models.CASCADE,
        related_name="readiness_assessments",
    )
    conducted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="smehub_readiness_assessments",
    )
    market_positioning_score = models.PositiveSmallIntegerField(
        help_text="1 (weak) – 5 (strong) on market positioning readiness.",
    )
    market_positioning_notes = models.TextField(blank=True)
    technical_robustness_score = models.PositiveSmallIntegerField(
        help_text="1 (weak) – 5 (strong) on technical robustness.",
    )
    technical_robustness_notes = models.TextField(blank=True)
    regulatory_compliance_score = models.PositiveSmallIntegerField(
        help_text="1 (weak) – 5 (strong) on regulatory compliance.",
    )
    regulatory_compliance_notes = models.TextField(blank=True)
    overall_notes = models.TextField(blank=True)
    conducted_at = models.DateTimeField(default=timezone.now)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-conducted_at"]
        indexes = [
            models.Index(fields=["cohort_member", "-conducted_at"]),
        ]

    def __str__(self) -> str:
        return f"Readiness #{self.pk} — {self.cohort_member}"

    @property
    def average_score(self) -> Decimal:
        """Mean of the three dimension scores (rounded to 2 dp)."""
        total = (
            self.market_positioning_score
            + self.technical_robustness_score
            + self.regulatory_compliance_score
        )
        return (Decimal(total) / Decimal(3)).quantize(Decimal("0.01"))

    def gaps(self, *, threshold: int = 3) -> list[str]:
        """Names of dimensions scoring at or below ``threshold`` (default 3).

        Used by mentors to focus the next session and by admins to prioritise
        targeted support — see PRD "identify gaps and areas for improvement".
        """
        out: list[str] = []
        if self.market_positioning_score <= threshold:
            out.append("market_positioning")
        if self.technical_robustness_score <= threshold:
            out.append("technical_robustness")
        if self.regulatory_compliance_score <= threshold:
            out.append("regulatory_compliance")
        return out


# ---------------------------------------------------------------------------
# Progress + Certificate  (FRSME-INC031, INC034 → INC037)
# ---------------------------------------------------------------------------

class ProgressRecord(models.Model):
    """Per-cohort-member progress aggregator. Updated by REP completion signals
    and manual training completion records.
    """

    cohort_member = models.OneToOneField(CohortMember, on_delete=models.CASCADE, related_name="progress")
    course_completions = models.JSONField(
        default=dict,
        blank=True,
        help_text="Map of course_id → {completed_at, source}.",
    )
    manual_trainings = models.JSONField(
        default=list,
        blank=True,
        help_text="List of {title, completed_at, recorded_by} entries.",
    )
    sessions_completed = models.PositiveIntegerField(default=0)
    last_milestone_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self) -> str:
        return f"Progress for {self.cohort_member}"


class Certificate(models.Model):
    cohort_member = models.OneToOneField(CohortMember, on_delete=models.CASCADE, related_name="certificate")
    certificate_number = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    pdf_file = models.FileField(upload_to=upload_to_smehub_certificates, null=True, blank=True)
    generated_at = models.DateTimeField(default=timezone.now)
    issued_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="smehub_certificates_issued",
    )

    def __str__(self) -> str:
        return f"Certificate {self.certificate_number} — {self.cohort_member}"
