"""Score aggregation, gap analysis, and engagement assembly.

Everything the notebook needs between raw widget values and the engine's
value math lives here — the notebook itself computes nothing.
"""

from __future__ import annotations

import re
from datetime import date, datetime
from typing import get_args

from .models import (
    CompetencyScore,
    DiagnosticConfig,
    Engagement,
    Function,
    OperationalBaseline,
    Participant,
    ValueAtStake,
)

FUNCTIONS: tuple[str, ...] = get_args(Function)


# ── Engagement identity ──────────────────────────────────────────────


def make_engagement_id(client_name: str, workshop_date: date) -> str:
    """``"Acme Corp!" + 2026-07-19 -> "acme_corp_2026-07-19"``."""
    slug = re.sub(r"[^a-z0-9]+", "_", client_name.lower()).strip("_") or "client"
    return f"{slug}_{workshop_date.isoformat()}"


def parse_participants(text: str) -> list[Participant]:
    """Parse ``"Name | Role | function; Name | Role | function"``.

    Forgiving by design — the facilitator types this live. Missing parts
    default (role empty, function ``other``); unknown functions map to
    ``other`` rather than erroring mid-workshop.
    """
    participants: list[Participant] = []
    for entry in text.split(";"):
        parts = [p.strip() for p in entry.split("|")]
        if not parts or not parts[0]:
            continue
        function = parts[2].lower() if len(parts) > 2 else "other"
        participants.append(Participant(
            name=parts[0],
            role=parts[1] if len(parts) > 1 else "",
            function=function if function in FUNCTIONS else "other",
        ))
    return participants


# ── Scores ───────────────────────────────────────────────────────────


def build_scores(config: DiagnosticConfig, raw: dict[str, tuple[int, str]],
                 scored_at: datetime,
                 scorer_role: str = "facilitator") -> list[CompetencyScore]:
    """``raw[competency_id] = (score, evidence)`` → validated scores, config order."""
    missing = [c.id for c in config.competencies if c.id not in raw]
    if missing:
        raise ValueError(f"unscored competencies: {missing}")
    return [
        CompetencyScore(
            competency_id=c.id, dimension=c.dimension,
            score=raw[c.id][0], evidence=raw[c.id][1].strip(),
            scorer_role=scorer_role, scored_at=scored_at,
        )
        for c in config.competencies
    ]


def dimension_rollup(config: DiagnosticConfig,
                     scores: list[CompetencyScore]) -> list[tuple[str, str, float]]:
    """``(dimension_id, dimension_name, mean score)`` per dimension, config order."""
    by_dim: dict[str, list[int]] = {d.id: [] for d in config.dimensions}
    for s in scores:
        by_dim[s.dimension].append(s.score)
    return [(d.id, d.name, sum(v) / len(v))
            for d in config.dimensions if (v := by_dim[d.id])]


def evidence_coverage(scores: list[CompetencyScore]) -> tuple[int, int]:
    """``(scores with evidence captured, total scores)``."""
    return sum(1 for s in scores if s.evidence), len(scores)


def heatmap_grid(config: DiagnosticConfig, scores: list[CompetencyScore]) -> dict:
    """Pure data for the 4×3 heatmap — rows are dimensions, three
    competencies per row in config order. Returned as plain lists so the
    visuals layer holds no logic."""
    by_id = {s.competency_id: s for s in scores}
    rows, z, text, hover = [], [], [], []
    for d in config.dimensions:
        comps = [c for c in config.competencies if c.dimension == d.id]
        rows.append(d.name)
        z.append([by_id[c.id].score for c in comps])
        text.append([f"{by_id[c.id].score}<br>{c.name}" for c in comps])
        hover.append([
            f"<b>{c.name}</b> — level {by_id[c.id].score}<br>"
            f"{c.level_descriptors[by_id[c.id].score]}<br>"
            f"<i>{by_id[c.id].evidence or 'no evidence captured'}</i>"
            for c in comps
        ])
    return {"rows": rows, "z": z, "text": text, "hover": hover,
            "cols": ["", "", ""]}


# ── Assembly ─────────────────────────────────────────────────────────


def build_engagement(*, config: DiagnosticConfig, client_name: str,
                     facilitator: str, workshop_date: date,
                     participants: list[Participant],
                     baseline: OperationalBaseline,
                     scores: list[CompetencyScore],
                     computed_value: ValueAtStake | None,
                     notes: str = "") -> Engagement:
    return Engagement(
        engagement_id=make_engagement_id(client_name, workshop_date),
        client_name=client_name.strip() or "Unnamed client",
        industry_config=config.industry,
        facilitator=facilitator.strip(),
        workshop_date=workshop_date,
        participants=participants,
        operational_baseline=baseline,
        scores=scores,
        computed_value=computed_value,
        notes=notes,
    )
