"""Shape-ledger MCP tools — the classification write/read surface (#2789).

The accounting model (note 2786): the snippet library records CANON (small);
the ledger accounts for EVERY extracted shape (total). These tools are how
agents move shapes out of `unclassified` — the todo state — and how they read
what still needs judgment. The ledger rows themselves are fed by the coverage
refresh; these tools only ever judge what the sync has seen.
"""
from __future__ import annotations

from scribe.mcp._context import current_user_id
from scribe.services import coverage as coverage_svc
from scribe.services import shape_ledger as shape_ledger_svc


async def classify_shapes(
    project_id: int, classifications: list[dict], via: str = "agent"
) -> dict:
    """Record judgments for a project's code shapes — in batch, as rows.

    EVERY shape in a bound repo should end up classified (note 2786):
      - `instance` of snippet N — it conforms to recorded canon (family-level
        canon in another project counts; that fully accounts for the shape).
      - `variant` of snippet N — a deliberate, named departure. `reason`
        (the why) is REQUIRED; it is the record.
      - `exempt` — judged genuinely one-off. `reason` REQUIRED.
      - `canonical` of snippet N — this row IS the snippet's reference
        (rarely set by hand; the coverage sync stamps these mechanically).
      - `unclassified` — withdraw a judgment; the shape rejoins the todo.

    Consumer maps belong HERE, not in prose: when an audit enumerates call
    sites of a canonical helper, each call site's defining shape is an
    `instance` row — a sentence in a verification detail cannot be sorted,
    queried, or diffed.

    Args:
        project_id: The project whose ledger is being judged.
        classifications: Objects of {path, symbol, status, kind?, snippet_id?,
            reason?, reason_code?}. path+symbol name the shape exactly as
            list_shapes shows it; kind ("sym"/"css") narrows when one file
            defines both. snippet_id is required for canonical/instance/
            variant; reason is required for variant/exempt. reason_code is
            an OPTIONAL index beside the prose (one of: scoped-css,
            one-off-handler, test-helper, convention-plumbing, pure-helper,
            generated, script, typed-record) so the ledger can be filtered
            and aggregated by kind of one-off — the prose stays the record.
            uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
            conformance (status + snippet_id) says what shape it is, uses
            says which canonical helpers it consumes — a service function
            can be an instance of the service-function convention AND use
            hash_token. Consumer maps are uses edges; list_shapes(uses=N)
            and get_snippet's `uses` read them.
        via: Who is judging — "agent" (default), "audit" (a sweep), or
            "import" (carrying maps recorded elsewhere).

    All-or-nothing: a structural error, a missing snippet target, or no write
    access applies NOTHING. Returns {"classified": N, "unmatched": [...]} —
    unmatched names shapes no live ledger row matches (the tree may have
    moved since you listed; re-run the project's coverage refresh to re-sync).
    """
    uid = current_user_id()
    return await shape_ledger_svc.classify_shapes(
        uid, project_id, classifications, via=via
    )


async def list_shapes(
    project_id: int,
    status: str = "",
    path: str = "",
    snippet_id: int = 0,
    include_vanished: bool = False,
    limit: int = 100,
    offset: int = 0,
    proposal: str = "",
    flag: str = "",
    compact: bool = False,
    uses: int = 0,
) -> dict:
    """Read a project's shape ledger — `status="unclassified"` IS the todo.

    Every extracted definition in the project's bound repos has a row here
    (fed by the coverage refresh). Filters compose:

    Args:
        status: canonical | instance | variant | exempt | scoped | unclassified.
            `scoped` (#2869) is the sync's mechanical stamp on one-offs by
            construction (a Vue component's scoped <style> rules and its
            <script setup> functions): accounted for, not judged, still
            proposed against / grouped / flagged, and overridable by any
            classify_shapes judgment. The human todo is `unclassified`.
        path: exact file, or a directory — matches everything beneath it
            (the coverage line's "largest" dirs go straight in here).
        snippet_id: rows classified against this snippet (instance/variant
            of it — conformance).
        uses: rows that CALL this snippet (#2870) — the consumer map proper,
            whatever shape each row is itself; edges come from judgments
            (classify_shapes uses=), the write-path hook, and the proposer's
            by-name reference hits.
        include_vanished: include shapes no longer in the tree (history).
        limit/offset: page through big ledgers (limit caps at 500).
        compact: rows as `path · symbol · kind · status · signature` plus
            snippet_id / by / proposal / diverges_from / recheck only when
            set — no commits, shas or timestamps. THE form for an audit:
            a full 500-row page fits the tool budget. The default rows carry
            everything (shape_history-grade bookkeeping).
        proposal: the proposer's queue (#2792) — "any", "canon" (rows the
            machine thinks are an instance of a snippet: `proposal` carries
            snippet_id, basis, score), "derive" (rows that repeat with NO
            canon: `proposal.group` names the family), or one basis
            (symbol/text/reference/signature/semantic).
        flag: the divergence readout (#2793) — "divergence": shapes new
            since the previous refresh in a directory where one canon
            dominates the judged siblings and NOT proposed as that canon
            (`diverges_from` names it: button B where button A is canon —
            classify it: instance if it should use the canon, variant with
            the why if deliberate); "recheck": judged instances/variants
            whose body changed since judged (the judgment stands; confirm
            it again with classify_shapes, or re-judge).

    Returns {"shapes": [...], "total": N} — total counts every match, not
    just this page. Each row's `classified_by` says who judged: agent /
    audit / import are judgments; `mechanical` is the canonical stamp the
    sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
    a snippet and then wrote code referencing/resembling it, so the shape
    was stamped an instance with the evidence in `reason`. A hook row is
    overridable by any classify_shapes call; it never overrides yours.

    THE FAST PATH through a big todo is the proposer's queue: every coverage
    refresh matches unclassified shapes against canon (strongest basis
    first: same symbol elsewhere → textual containment → body references
    the canon → signature resemblance → semantic) and attaches a
    `proposal` to each row it can speak for. Review `proposal="canon"` by
    snippet or directory, then confirm_shape_proposals the ones that hold —
    hundreds at a time — and classify_shapes the rest (variant/exempt, or
    instance of a different snippet). `proposal="derive"` lists the
    derive-first candidates: a repeating shape with NO recorded canon is
    never N loose classifications — consolidate onto a reference,
    create_snippet it, then classify the group against it.
    """
    uid = current_user_id()
    rows, total = await shape_ledger_svc.list_project_shapes(
        uid, project_id,
        status=status, path=path, snippet_id=snippet_id,
        include_vanished=include_vanished, limit=limit, offset=offset,
        proposal=proposal, flag=flag, uses=uses,
    )
    return {
        "shapes": [r.to_compact() if compact else r.to_dict() for r in rows],
        "total": total,
    }


async def classify_shapes_by_rule(
    project_id: int,
    path: str,
    status: str,
    pattern: str = "",
    kind: str = "",
    snippet_id: int = 0,
    reason: str = "",
    via: str = "agent",
    include_judged: bool = False,
    reason_code: str = "",
    uses: list[int] | None = None,
) -> dict:
    """The sweep form of classify_shapes: ONE judgment applied to every
    unclassified shape under a directory whose symbol matches a glob.

    For the long tail an audit judges by family, not by row — "every scoped
    rule under frontend/src/views is exempt: styles one element of its view",
    "every `*_scheduler.py` symbol is an instance of ScheduledJob" — where
    listing 900 rows and sending them back is the whole cost. The row form
    stays the precise tool; reach for it when each row gets its own reason.

    Args:
        project_id: The project whose ledger is being judged.
        path: A file, or a directory and everything beneath it. Required —
            a sweep names what it judges.
        status: instance | variant | exempt | unclassified (canonical is the
            sync's stamp, not a sweep's).
        pattern: Shell glob on the symbol (`*_rows`, `_*`, `modal-*`, `*`);
            "" = every symbol under path.
        kind: "sym" or "css" to narrow; "" = both.
        snippet_id: Required for instance/variant — the canon judged against.
        reason: Required for variant/exempt — the why, recorded on every row.
        via: "agent" (default) | "audit" | "import".
        reason_code: Optional catalogue code beside the reason (see
            classify_shapes) — a sweep is exactly where one applies.
        uses: Optional snippet ids every matched shape CALLS (#2870) — e.g.
            "every *_scheduler.py symbol uses ScheduledJob".
        include_judged: By default only unjudged rows are touched —
            `unclassified` and the sync's mechanical `scoped` stamp — a
            sweep never silently overwrites a judgment. True re-judges every
            matching live row (use to re-confirm after a recheck, or to
            revise a family you judged earlier).

    One transaction: applies whole or not at all. Returns
    {"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted)
    so you can see what the rule reached; N = 0 means the rule matched
    nothing live and unclassified — widen the pattern or refresh coverage.
    """
    uid = current_user_id()
    try:
        return await shape_ledger_svc.classify_shapes_where(
            uid, project_id, path=path, status=status, pattern=pattern,
            kind=kind, snippet_id=snippet_id or None, reason=reason or None,
            via=via, include_judged=include_judged,
            reason_code=reason_code or None, uses=uses or None,
        )
    except ValueError as exc:
        return {"error": str(exc)}


async def shape_history(
    project_id: int, path: str, symbol: str = "", limit: int = 200
) -> dict:
    """What was used here, when, and why — a shape's (or a directory's)
    history from the ledger (#2793).

    `shapes` are the current rows at `path` (a file, or a directory and
    everything beneath it; `symbol` narrows to one definition) with
    first/last-seen commits, vanished_at, and the standing judgment;
    `events` are the state changes, oldest first: `classified` (status,
    snippet_id, who, why — one per judgment, so a shape that was an instance
    of #N and later a variant of #M shows both), `vanished`, `reappeared`,
    `drifted` (the body moved under a judgment; see list_shapes flag=
    "recheck"). Each event carries the commit the tree was read at.

    Read it as a timeline: "instance of #N from <first classified at>,
    re-judged variant of #M at <at> because <reason>, vanished at <commit>".
    Read-only; requires read access to the project.
    """
    uid = current_user_id()
    return await shape_ledger_svc.shape_history(
        uid, project_id, path, symbol=symbol, limit=limit
    )


async def confirm_shape_proposals(
    project_id: int,
    snippet_id: int = 0,
    path: str = "",
    basis: str = "",
    min_score: float = 0.0,
) -> dict:
    """Confirm the proposer's canon proposals you have reviewed, in batch.

    The machine proposes, judgment classifies (#2792): each matching row —
    live, unclassified, carrying a `proposal` with a snippet_id — becomes
    `instance` of that snippet, classified_by="agent", reason naming the
    basis and score. Narrow to what you actually looked at: at least one of
    snippet_id (confirm one canon's whole queue after reading its
    `list_shapes(proposal="canon", ...)` page), path (a directory you
    audited), or basis (e.g. "symbol" and "reference" are near-certain;
    "semantic" deserves a look first) is required — a bare confirm-all is
    not a judgment. min_score trims a basis's tail.

    Proposals you do NOT confirm are judged with classify_shapes (variant,
    exempt, or instance of a different snippet) — any judgment retires the
    proposal. Requires write access. Returns {"confirmed": N}.
    """
    uid = current_user_id()
    try:
        return await shape_ledger_svc.confirm_proposals(
            uid, project_id, snippet_id=snippet_id, path=path, basis=basis,
            min_score=min_score,
        )
    except ValueError as exc:
        return {"error": str(exc)}


async def refresh_pattern_coverage(project_id: int) -> dict:
    """Seed or refresh the project's shape ledger NOW, and return the readout.

    The synchronous form of the arrival-moment background seed (#2802):
    downloads the bound repos through the OWNER's forge connections, upserts
    every extracted shape into the ledger (new shapes arrive `unclassified`),
    re-stamps snippet reference locations as canonical, and recomputes the
    accounting. Reach for it when the ledger must be current before you act —
    a classification batch about to run, a coverage question asked directly —
    rather than waiting on the background seed an enter_project triggers.

    Takes seconds, not milliseconds (it moves repo archives). Requires write
    access to the project. Errors name the fix: no forge connection → the
    owner adds one (Settings → Integrations → Git Forges); no served repo →
    bind_repo on a host a connection serves.

    The refresh is also when the mechanical proposer runs (#2792): with the
    repo bodies in hand it matches every changed unclassified shape against
    canon and records proposals (see list_shapes proposal=), then regroups
    the derive-first candidates. Semantic matching is capped per refresh, so
    a large ledger's queue grows across refreshes rather than in one.

    Returns the accounting payload — total, accounted, counts by status,
    unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
    confirmation), `derive_groups` (the biggest repeats-with-no-canon
    families), `derive_new` (copies that joined a family since the previous
    refresh — the drift to act on now: derive the canon, don't queue an
    audit), `proposer` (what this refresh examined) — plus
    `pattern_coverage`, the same one-line summary enter_project carries.
    """
    uid = current_user_id()
    coverage = await coverage_svc.refresh_for_caller(uid, project_id)
    return {
        "pattern_coverage": coverage_svc.coverage_line(coverage),
        **coverage,
    }


def register(mcp) -> None:
    for fn in (
        classify_shapes, classify_shapes_by_rule, list_shapes,
        refresh_pattern_coverage, confirm_shape_proposals, shape_history,
    ):
        mcp.tool(name=fn.__name__)(fn)
