"""Project CRUD MCP tools — thin wrappers over services/projects.py.

Mirrors existing fable-mcp project tool contracts. Note: there is no
fable_delete_project here (matches existing fable-mcp surface). To stop
working on a project, update its status to 'archived'.

The LLM-era similarity-check / 'confirmed' guard from services/tools/projects.py
is intentionally NOT replicated here — Claude is the client, not a weak local
model that needs that guardrail. services.projects.create_project creates
directly with no similarity warning.

The auto-summary regeneration that services.projects.update_project triggers
async will be removed in Phase 7 (it's an LLM call). The wrapper makes no
assumption either way; once the service-layer side effect is gone, this code
keeps working.
"""
from __future__ import annotations

from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import coverage as coverage_svc
from scribe.services import design_systems as design_systems_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.background import spawn
from scribe.services.note_usage import record_surfaced


async def list_projects() -> dict:
    """List all Scribe projects for the current user.

    Returns id, title, description, goal, status (active/paused/completed/archived), color,
    and a short auto-generated summary for each project.
    """
    uid = current_user_id()
    rows = await projects_svc.list_projects(uid)
    return {"projects": [p.to_dict() for p in rows]}


async def enter_project(project_id: int) -> dict:
    """Session-start handshake: load full context for working on a project.

    Call this FIRST whenever you're about to do project-scoped work
    (start_planning, create_task, update_*, anything that takes a project_id).
    One round-trip returns the project, its applicable rules (both rulebook-
    subscribed and project-scoped), milestone progress, open tasks, and
    recently-updated notes — everything you need to know the lay of the land
    before mutating.

    No persistent server state: this is a read snapshot. Re-call if the
    session goes idle long enough that the data feels stale.

    Args:
        project_id: The project to enter.

    Returns a dict with keys: project, milestone_summary, applicable_rules,
    project_rules, subscribed_rulebooks, applicable_rules_truncated,
    open_tasks, recent_notes, design_system, systems, pattern_coverage —
    plus systems_bootstrap, present only when it applies (see below).

    `pattern_coverage` (usually null) is the shape-accounting line — how many
    of the bound repo's extracted shapes carry a classification against canon
    (note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
    12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
    unclassified, largest: internal/api". Unclassified IS the todo: as you
    touch code in those areas, classify the shapes you can (instances of
    recorded canon, deliberate variants, one-off exemptions) and record the
    canon that's missing with create_snippet. A null line on a forge-served
    project usually means the ledger is seeding in the background (entering
    triggers it); refresh_pattern_coverage computes it on the spot.

    `systems` is the project's vocabulary of named subsystems/areas. It is
    returned here so you can TAG as you write: when creating or meaningfully
    updating a record, ask which of these areas it is about and pass their ids
    as `system_ids`. If the area a record describes is missing from this list,
    create it with create_system rather than leaving the area unmodelled. Read
    a subsystem's accumulated records with list_system_records.

    `inception` (milestone 297) appears ONLY when the project is yours and
    nobody has decided what it inherits: it carries the current defaults
    (which always-on rulebooks bind, design system, Systems), what to ask the
    operator — once — and the decide_project_inception call that answers it;
    it repeats on every enter until a decision is recorded.

    `systems_bootstrap` appears ONLY when the project has many records and no
    Systems at all — act on it before starting other work: create_system a
    starter vocabulary from the areas the project's records name, directly
    and without asking permission, preferring the standard names the ask
    lists. It stops appearing the moment the first System exists.

    `design_system` is null unless the project points at one. When present it
    carries the chain-merged guidance (the house style AND this project's
    departures from it) plus a summary of the token set — treat it as binding
    for any UI you write, and pull the values with resolve_design_system or
    get_design_system_stylesheet before reaching for a literal.

    Entering a project also SCOPES the session: reference and offer work on
    this project only, and pass its id to search / list_* so results stay
    inside it. If something clearly belongs to a different project, say so and
    ask before switching — never silently operate cross-project. The active
    project does not stick on the server (each call is self-contained);
    carrying its id forward is on you.

    Don't wait to be told which project is in scope. When work clearly belongs
    to a project but none is entered, look for a match yourself (list_projects
    / search on the repo or subject), propose it, and enter it once the
    operator confirms; if nothing matches, offer to create one — confirming
    name and goal first, never guessing a project into existence.
    """
    uid = current_user_id()
    project = await projects_svc.get_project(uid, project_id)
    if project is None:
        raise ValueError(f"project {project_id} not found")

    applicable = await rulebooks_svc.get_applicable_rules(
        project_id=project_id, user_id=uid,
    )
    milestone_summary = await milestones_svc.get_project_milestone_summary(
        uid, project_id,
    )
    open_tasks, _ = await notes_svc.list_notes(
        uid, is_task=True, project_id=project_id,
        status=["todo", "in_progress"], sort="updated_at", limit=10,
    )
    recent_notes, _ = await notes_svc.list_notes(
        uid, is_task=False, project_id=project_id,
        sort="updated_at", limit=5,
    )
    # The tagging vocabulary. Surfaced HERE because an instruction to "tag
    # records to Systems" is only executable if the list is in front of the
    # agent when it writes — which it never was, and tagging stopped within
    # three days of the feature landing (#2546's audit).
    systems = await systems_svc.list_systems(uid, project_id)

    # The arrival-moment half of the bootstrap ask (#2683): session start is
    # when the agent has just read the project map and is not yet deep in a
    # task — the one moment minting a starter vocabulary is cheap. The
    # write-moment half rides untagged-record responses (attach_systems);
    # both retire the instant the first System exists.
    systems_bootstrap = None
    if not systems:
        systems_bootstrap = await systems_tools.bootstrap_systems_ask(
            uid, project_id
        )

    # The inception ask (milestone 297): a project nobody has decided on
    # inherits its defaults silently — always-on rulebooks, no design system,
    # no Systems. Owner-only (deciding is the owner's), and only until a
    # decision is recorded; the key is ABSENT otherwise (#2483).
    inception_ask = None
    if project.user_id == uid and not inception_svc.is_decided(project):
        inception_ask = await inception_svc.inception_ask(uid, project_id)

    # Probably the largest surfacing by volume, and it emitted nothing — so
    # the pulls it caused floated unattributed and the surfaced:pulled ratio
    # ran against a denominator missing its biggest contributor (#2477). An
    # AMBIENT source: these are top-N-by-recency, not a ranked choice, and the
    # readout counts them apart so dead-weight detection isn't poisoned by
    # "recently updated in a project you opened".
    record_surfaced(
        user_id=uid,
        note_ids=[int(t.id) for t in open_tasks] + [int(n.id) for n in recent_notes],
        source="enter_project",
    )
    # A project need not have one, and most installs won't — null is ordinary
    # here, not a missing prerequisite.
    design_system = None
    if project.design_system_id:
        design_system = await design_systems_svc.design_context(
            uid, project.design_system_id,
        )

    # Cache read ONLY — computing coverage moves a repo tarball and never
    # belongs in this request path. Null is the ordinary state (no forge, or
    # never computed); the line appears exactly when there is evidence. Read
    # on the OWNER's id: bindings and the cache live with the project owner.
    coverage = await coverage_svc.cached_coverage(
        project.user_id or uid, project_id
    )
    # Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
    # the BACKGROUND — entering is the moment the number is wanted, and the
    # UI button must not be the only path. This enter stays fast; the next
    # one carries the line. Forge-less projects exit the seed quietly.
    spawn(
        coverage_svc.refresh_if_stale(
            project.user_id or uid, project_id, cached=coverage
        ),
        site="enter_project.coverage_seed",
    )

    out = {
        "project": project.to_dict(),
        "pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
        # Trimmed to what tagging needs. The full charter is get_system's job —
        # this list rides along on every session start, so it stays lean.
        "systems": [
            {
                "id": s.id, "name": s.name,
                "description": (s.description or "").split("\n")[0][:200],
            }
            for s in systems
        ],
        "design_system": design_system,
        "milestone_summary": milestone_summary,
        **rulebooks_svc.rules_payload(applicable),
        "open_tasks": [
            {
                "id": t.id, "title": t.title, "status": t.status,
                "priority": t.priority, "task_kind": t.task_kind,
                "milestone_id": t.milestone_id,
            }
            for t in open_tasks
        ],
        "recent_notes": [
            {
                "id": n.id, "title": n.title,
                "updated_at": n.updated_at.isoformat() if n.updated_at else None,
            }
            for n in recent_notes
        ],
    }
    # Attached only when it applies — a key that usually says null trains
    # readers to skip it (#2483), and this one exists to be acted on.
    if systems_bootstrap:
        out["systems_bootstrap"] = systems_bootstrap
    if inception_ask:
        out["inception"] = inception_ask
    return out


async def get_project(project_id: int) -> dict:
    """Fetch a Scribe project by ID.

    Returns full project fields, a milestone_summary list, and the
    rulebook-applicable_rules / subscribed_rulebooks pair the assistant
    should consult when working on this project.
    """
    uid = current_user_id()
    project = await projects_svc.get_project(uid, project_id)
    if project is None:
        raise ValueError(f"project {project_id} not found")
    data = project.to_dict()
    data["milestone_summary"] = await milestones_svc.get_project_milestone_summary(
        uid, project_id,
    )
    applicable = await rulebooks_svc.get_applicable_rules(
        project_id=project_id, user_id=uid,
    )
    data.update(rulebooks_svc.rules_payload(applicable))
    return data


def _inception_choices(
    exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
    """The tool args → an inception choices object, or None when no inception
    arg was given at all (a bare create stays undecided and enter_project
    asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
    system."""
    if (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
            and not design_system_id and seed_systems is None):
        return None
    return {
        "exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
        "subscribe_rulebooks": list(subscribe_rulebooks or []),
        "design_system_id": None if design_system_id in (0, -1) else design_system_id,
        "seed_systems": bool(seed_systems),
    }


async def create_project(
    title: str,
    description: str = "",
    goal: str = "",
    status: str = "active",
    color: str = "",
    exclude_always_on_rulebooks: list[int] | None = None,
    subscribe_rulebooks: list[int] | None = None,
    design_system_id: int = 0,
    seed_systems: bool | None = None,
) -> dict:
    """Create a new project in Scribe — and decide what it inherits.

    A project's inheritance is a decision, not a default (milestone 297):
    before calling, ask the operator the four inception questions and pass
    the answers; a project created without any of them is UNDECIDED and
    enter_project will ask until decide_project_inception records it.
    Defaults if nobody decides: every always-on rulebook binds, nothing is
    subscribed, no design system, no Systems.

    Args:
        title: Project name (required).
        description: Short summary of what the project is.
        goal: The desired outcome or definition of done for the project.
        status: one of active (default), paused, completed, archived.
        color: Optional hex colour for the project card (e.g. "#6366f1").
        exclude_always_on_rulebooks: always-on rulebook ids this project does
            NOT inherit ([] = inherit them all). list_rulebooks shows which are
            always_on.
        subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
        design_system_id: the design system this project's UI is built from
            (list_design_systems); -1 = explicitly none; 0 = not stated.
        seed_systems: true mints the standard starter Systems (CI & Release,
            Auth & Access, …) so records can be tagged from day one.
    """
    uid = current_user_id()
    project = await projects_svc.create_project(
        uid,
        title=title,
        description=description,
        goal=goal,
        status=status,
        color=color or None,
    )
    data = project.to_dict()
    choices = _inception_choices(
        exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
    )
    if choices is not None:
        decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
        data["inception"] = decided["inception"]
        data["inception_effects"] = decided["effects"]
    else:
        data["inception_hint"] = (
            "Undecided: this project inherits its defaults until "
            "decide_project_inception records what it should inherit "
            "(enter_project will ask)."
        )
    return data


async def decide_project_inception(
    project_id: int,
    exclude_always_on_rulebooks: list[int] | None = None,
    subscribe_rulebooks: list[int] | None = None,
    design_system_id: int = 0,
    seed_systems: bool | None = None,
) -> dict:
    """Record what a project inherits — answer enter_project's `inception` ask,
    or re-decide later (milestone 297).

    Owner-only. Applies the effects through the ordinary tools' paths —
    exclude_always_on_rulebook, subscribe_project_to_rulebook,
    set_project_design_system, the standard Systems seed — and writes the
    decision on the project last, so get_project/enter_project can say why
    the project has the rules, design and Systems it has. Re-deciding is
    additive for exclusions/subscriptions (use include_always_on_rulebook /
    unsubscribe_project_from_rulebook to undo one), replaces the design
    system, and never re-seeds Systems a project already has.

    Args: as create_project's inception args. Passing nothing records an
    inherit-all decision (every always-on rulebook binds, no subscriptions,
    no design system, no seed) — a valid answer, stated.
    """
    uid = current_user_id()
    choices = _inception_choices(
        exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
    ) or {}
    decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
    return {"project_id": project_id, **decided}


async def update_project(
    project_id: int,
    title: str = "",
    description: str = "",
    goal: str = "",
    status: str = "",
    color: str = "",
) -> dict:
    """Update an existing Scribe project. Only explicitly provided fields are changed.

    Args:
        project_id: ID of the project to update.
        title: New title, or omit to leave unchanged.
        description: New description, or omit to leave unchanged.
        goal: New goal/definition-of-done, or omit to leave unchanged.
        status: New status — one of active, paused, completed, archived.
        color: New hex colour, or omit to leave unchanged.
    """
    uid = current_user_id()
    fields: dict = {}
    if title:
        fields["title"] = title
    if description:
        fields["description"] = description
    if goal:
        fields["goal"] = goal
    if status:
        fields["status"] = status
    if color:
        fields["color"] = color
    project = await projects_svc.update_project(uid, project_id, **fields)
    if project is None:
        raise ValueError(f"project {project_id} not found")
    return project.to_dict()


async def delete_project(project_id: int) -> dict:
    """Move a project to the trash (recoverable). Its milestones, tasks, and notes
    go with it as one batch. Restore via restore(batch_id)."""
    uid = current_user_id()
    batch = await trash_svc.delete(uid, "project", project_id)
    if batch is None:
        raise ValueError(f"project {project_id} not found")
    return {"deleted_batch_id": batch,
            "message": f"Project {project_id} + its contents moved to trash. Restore with restore('{batch}')."}


def register(mcp) -> None:
    for fn in (
        list_projects,
        enter_project,
        get_project,
        create_project,
        update_project,
        delete_project, decide_project_inception,
    ):
        mcp.tool(name=fn.__name__)(fn)
