"""
Session engine — the discovery workshop's "math".

There are no dollars here; the quantities a facilitator and client watch are
**status** and **progress**. This module owns the schema and all of the
logic, but none of the content: the topic bank itself lives in the
``topic-bank`` cell of ``notebooks/cx_discovery.ipynb`` (content belongs on
the Jupyter surface, where it is edited; ``.py`` files hold code only).

* :class:`Topic` / :class:`SubTopic` — the schema the bank is written in,
* the topic-status vocabulary (:data:`STATUSES`) and its display glyphs,
* :func:`progress` — the "3 / 8 topics complete" headline and its ratios,
* :func:`subtopic_checklist` — the live tick-list for the active topic,
* :func:`build_session` — assembles the full render/export state from the
  raw widget inputs (per-topic status + free-text notes),
* :func:`session_json` — the machine-readable export payload (Pattern §5).

Everything is a pure function of ``(topics, status_by_topic, notes_by_topic,
done_subtopics)`` so the in-notebook gate can pin it and the export can dump
it. Unknown / missing keys degrade gracefully to "not started" so a
half-filled live session never crashes the render.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any


# ── Schema — what the notebook's topic-bank cell is written in ───────
@dataclass(frozen=True)
class SubTopic:
    """A discussion thread within a topic; its title shows on stage when the
    topic is active, its prompts are the backstage facilitation script."""

    key: str
    title: str
    prompts: tuple[str, ...] = ()


@dataclass(frozen=True)
class Topic:
    """A client-facing discovery topic: the unit the progress bar counts."""

    key: str
    title: str
    scope: str
    minutes: int
    subtopics: tuple[SubTopic, ...] = field(default_factory=tuple)

    @property
    def prompt_count(self) -> int:
        return sum(len(st.prompts) for st in self.subtopics)


# ── Status vocabulary ────────────────────────────────────────────────
# Order matters: it's the selector order and the legend order. "skipped"
# is terminal-but-not-complete (a topic consciously set aside), so it
# counts as resolved for the agenda but not toward completion.
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETE = "complete"
SKIPPED = "skipped"

STATUSES: tuple[str, ...] = (NOT_STARTED, IN_PROGRESS, COMPLETE, SKIPPED)

STATUS_LABEL: dict[str, str] = {
    NOT_STARTED: "Not started",
    IN_PROGRESS: "In progress",
    COMPLETE: "Complete",
    SKIPPED: "Skipped",
}

# Stage glyphs — calm, unambiguous at a glance on a shared screen.
STATUS_GLYPH: dict[str, str] = {
    NOT_STARTED: "○",   # open circle — pending
    IN_PROGRESS: "◐",   # half — under discussion
    COMPLETE: "●",      # filled — done
    SKIPPED: "⊘",       # slashed — set aside
}

# Brand-aligned status colors (docs/brand.md). Muted gray pending, Future
# Blue active, Success Green complete, light gray skipped.
STATUS_COLOR: dict[str, str] = {
    NOT_STARTED: "#a9b2b8",
    IN_PROGRESS: "#0072bc",
    COMPLETE: "#00a34c",
    SKIPPED: "#c3c7ca",
}


def normalize_status(value: str | None) -> str:
    """Coerce any widget value to a known status; default NOT_STARTED."""
    return value if value in STATUS_LABEL else NOT_STARTED


# ── Progress ─────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Progress:
    """The headline the client watches: completed vs. total topics, plus
    the resolved (complete + skipped) count that drives the agenda burn."""

    total: int
    completed: int          # status == complete
    skipped: int
    in_progress: int
    not_started: int

    @property
    def resolved(self) -> int:
        """Topics no longer open for discussion (complete or skipped)."""
        return self.completed + self.skipped

    @property
    def fraction(self) -> float:
        """Completed / total in [0, 1] — the progress-bar fill."""
        return self.completed / self.total if self.total else 0.0

    @property
    def label(self) -> str:
        return f"{self.completed}/{self.total} topics complete"


def progress(
    topics: tuple[Topic, ...], status_by_topic: dict[str, str]
) -> Progress:
    """Tally topic statuses into the headline :class:`Progress`."""
    counts = {s: 0 for s in STATUSES}
    for t in topics:
        counts[normalize_status(status_by_topic.get(t.key))] += 1
    return Progress(
        total=len(topics),
        completed=counts[COMPLETE],
        skipped=counts[SKIPPED],
        in_progress=counts[IN_PROGRESS],
        not_started=counts[NOT_STARTED],
    )


def active_topic_key(
    topics: tuple[Topic, ...], status_by_topic: dict[str, str]
) -> str | None:
    """The topic to spotlight on stage: the first in-progress topic, else
    the first not-started one, else None (everything resolved)."""
    for t in topics:
        if normalize_status(status_by_topic.get(t.key)) == IN_PROGRESS:
            return t.key
    for t in topics:
        if normalize_status(status_by_topic.get(t.key)) == NOT_STARTED:
            return t.key
    return None


# ── Sub-topic checklist (live, for the active topic) ─────────────────
@dataclass(frozen=True)
class ChecklistItem:
    key: str
    title: str
    done: bool


def subtopic_checklist(
    topic: Topic, done_subtopics: set[str] | frozenset[str]
) -> list[ChecklistItem]:
    """The active topic's sub-topics as a tick-list. ``done_subtopics``
    holds the fully-qualified ``"<topic_key>.<subtopic_key>"`` ids the
    facilitator has ticked."""
    return [
        ChecklistItem(st.key, st.title, f"{topic.key}.{st.key}" in done_subtopics)
        for st in topic.subtopics
    ]


def subtopic_id(topic_key: str, subtopic_key: str) -> str:
    """The fully-qualified id used in ``done_subtopics`` and the export."""
    return f"{topic_key}.{subtopic_key}"


# ── Agenda ───────────────────────────────────────────────────────────
def agenda_minutes(topics: tuple[Topic, ...]) -> int:
    """Sum of the nominal per-topic minute budgets."""
    return sum(t.minutes for t in topics)


# ── Full session state (render + export) ─────────────────────────────
@dataclass(frozen=True)
class TopicState:
    key: str
    title: str
    scope: str
    minutes: int
    status: str
    status_label: str
    glyph: str
    color: str
    notes: str
    subtopics_total: int
    subtopics_done: int
    # The covered sub-topic KEYS in canonical bank order — the export must
    # say WHICH threads were discussed, not just how many.
    subtopics_covered: tuple[str, ...] = ()


def build_session(
    topics: tuple[Topic, ...],
    status_by_topic: dict[str, str],
    notes_by_topic: dict[str, str] | None = None,
    done_subtopics: set[str] | frozenset[str] | None = None,
) -> dict[str, Any]:
    """Assemble the complete session state the notebook renders and exports.

    Pure function of the bank and the three raw inputs; safe against
    missing keys.
    """
    notes_by_topic = notes_by_topic or {}
    done_subtopics = frozenset(done_subtopics or ())
    by_key = {t.key: t for t in topics}

    topic_states: list[TopicState] = []
    for t in topics:
        status = normalize_status(status_by_topic.get(t.key))
        covered = tuple(
            st.key for st in t.subtopics
            if subtopic_id(t.key, st.key) in done_subtopics
        )
        topic_states.append(
            TopicState(
                key=t.key,
                title=t.title,
                scope=t.scope,
                minutes=t.minutes,
                status=status,
                status_label=STATUS_LABEL[status],
                glyph=STATUS_GLYPH[status],
                color=STATUS_COLOR[status],
                notes=(notes_by_topic.get(t.key) or "").strip(),
                subtopics_total=len(t.subtopics),
                subtopics_done=len(covered),
                subtopics_covered=covered,
            )
        )

    prog = progress(topics, status_by_topic)
    active = active_topic_key(topics, status_by_topic)
    return {
        "topics": topic_states,
        "progress": prog,
        "active_topic_key": active,
        "active_topic": by_key[active] if active else None,
        "agenda_minutes": agenda_minutes(topics),
    }


def session_json(
    session: dict[str, Any], meta: dict[str, Any] | None = None
) -> dict[str, Any]:
    """The machine-readable export payload (Pattern §5): every captured
    answer/status/note as plain JSON, ready to feed an LLM drafting the
    survey write-up or business case. Plotly/HTML never carry this — the
    appendix does."""
    prog: Progress = session["progress"]
    return {
        "assessment": "CX_Discovery_Workshop",
        "instrument": "CX Exploration & Discovery Workshop",
        "meta": meta or {},
        "progress": {
            "total_topics": prog.total,
            "completed": prog.completed,
            "skipped": prog.skipped,
            "in_progress": prog.in_progress,
            "not_started": prog.not_started,
            "fraction_complete": round(prog.fraction, 4),
            "label": prog.label,
        },
        "agenda_minutes": session["agenda_minutes"],
        "active_topic": session["active_topic_key"],
        "topics": [
            {
                "key": ts.key,
                "title": ts.title,
                "scope": ts.scope,
                "status": ts.status,
                "minutes": ts.minutes,
                "subtopics_done": ts.subtopics_done,
                "subtopics_total": ts.subtopics_total,
                "subtopics_covered": list(ts.subtopics_covered),
                "notes": ts.notes,
            }
            for ts in session["topics"]
        ],
    }
