"""System CRUD + record-association MCP tools — wrappers over services/systems.py.

A System is a per-project, reusable, self-describing subsystem/area that any
record (note, task, or issue) can be associated with — so research, build-work,
and corrective work line up under the same area and recurring problem-spots are
visible. The service enforces the multi-user ACL (project permission); these
tools are thin wrappers.

Sentinels (match the milestone/task tool conventions):
  - name="" / description="" / color="" / status="" → "leave unchanged" on update
  - order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
"""
from __future__ import annotations

from scribe.mcp._context import current_user_id
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc

# Below this, a project is young enough that the mild "which area is this
# about?" question stays proportionate; at or above it, a zero-Systems project
# has demonstrated that the question never converts (#2683 — Minstrel reached
# 282 records without a single System) and the ask escalates.
_BOOTSTRAP_MIN_RECORDS = 20
_BOOTSTRAP_TITLES = 6

# The standard vocabulary (#2798): area names that recur across software
# projects, offered so "CI & Release" means the same thing in every project
# on the instance. Consistency comes from the shared names — NOT from asking
# the operator to approve each System; agents mint directly. Generic by
# design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl.
# The standard vocabulary lives with the service (services/systems.
# STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this
# ask names it, one list for both.
_STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS)


async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
    """The escalated vocabulary-bootstrap ask for a mature zero-Systems project.

    The generic zero-state question habituates: identical on every record,
    maximal in scope ("invent the taxonomy"), asked at wrap-up time — so
    organic sessions skip it forever and only audit-shaped sessions ever mint
    (#2683). What separates the nudges that convert from the prose that
    doesn't (the duplicate gate, the prior-art "already defined in 2 files")
    is the project's OWN evidence in the ask — so this one carries the record
    count and the recent titles, and asks for a concrete deliverable: create
    a starter set directly, preferring the standard cross-project names.
    Deliberately NOT an approval flow (#2798): the operator is not a
    permission gate for vocabulary; the standard names carry consistency.

    Self-retiring by construction: callers only reach for it while the
    project has zero Systems, so the first create_system ends it everywhere.
    Returns None below the record threshold or on any failure (fail-open —
    a hint must never break the call it rides on).
    """
    try:
        recent, total = await notes_svc.list_notes(
            user_id, project_id=project_id, sort="updated_at",
            limit=_BOOTSTRAP_TITLES,
        )
    except Exception:
        return None
    if total < _BOOTSTRAP_MIN_RECORDS:
        return None
    titles = "; ".join(
        '"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
    )
    return (
        f"This project has {total} records and NO Systems modelled — none of "
        "them can be tagged to an area, so recurring problem-spots stay "
        "invisible. Bootstrap the vocabulary now, in this session, without "
        "asking permission — creating Systems is your call, not an approval "
        f"flow. From the areas the records themselves name (recent: {titles}), "
        "create_system 3-6 Systems, each with a one-paragraph charter, then "
        "tag this record (system_ids=[...]). Where an area fits a standard "
        f"name, use it verbatim so it means the same thing in every project: "
        f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
        "duplicate gate guards sprawl. This ask repeats until the first "
        "System exists; answering it once retires it for every future record."
    )


async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
    """The Systems question, for an untagged project record.

    Not a tool — attach_systems() rides this on tool responses so the question
    arrives in-band at the exact moment a record is touched. It is ONE
    question regardless of vocabulary state ("which area is this about, and is
    it modelled?"); only the vocabulary listing varies, because an empty
    vocabulary is not an exemption — it is the question at its most urgent
    (#2562, #2569). Instruction prose alone demonstrably doesn't fire at write
    time; in-band behavior (the duplicate gate) does.

    In a MATURE zero-Systems project the question escalates to the bootstrap
    ask instead (#2683): the mild form demonstrably never converts there, and
    an evidence-carrying, deliverable-shaped ask is the form that does.
    """
    # Fail-open like the dedup gate: a hint must never break the call.
    try:
        systems = await systems_svc.list_systems(user_id, project_id)
    except Exception:
        return None
    if systems:
        vocab = "Existing Systems: " + ", ".join(
            f"#{s.id} {s.name}" for s in systems
        ) + "."
    else:
        ask = await bootstrap_systems_ask(user_id, project_id)
        if ask:
            return ask
        vocab = "This project has no Systems yet."
    return (
        "This record is untagged — which area(s) of the project is it about? "
        f"{vocab} Tag it (system_ids=[...]) — cross-cutting records like "
        "audits take several; create_system any area it concerns that isn't "
        "modelled yet (a sweep that names areas is the moment to mint them, "
        "and the duplicate gate guards against sprawl). Leave it untagged "
        "only if it is genuinely about no particular area."
    )


async def attach_systems(
    caller_id: int,
    owner_id: int,
    data: dict,
    note_id: int,
    project_id: int | None,
) -> None:
    """Attach a record's Systems to its payload — or the Systems question.

    ONE seam for every tool that returns a project record, read or write: a
    tagged record shows its areas (the touching-a-System reflex needs the
    affiliation visible on read, not just settable on write), an untagged
    project record carries the question instead. Neither field is ever
    attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a
    field that always says nothing trains readers to skip fields). The hint
    goes only to the record's owner: tagging someone else's record in someone
    else's project is not the caller's call to make. Fail-open — decoration
    must never break the call it rides on.
    """
    try:
        systems = await systems_svc.list_record_systems(owner_id, note_id)
        if systems:
            data["systems"] = [s.to_dict() for s in systems]
        elif project_id and caller_id == owner_id:
            hint = await untagged_systems_hint(owner_id, project_id)
            if hint:
                data["systems_hint"] = hint
    except Exception:
        pass


async def create_system(
    project_id: int,
    name: str,
    description: str = "",
    color: str = "",
) -> dict:
    """Create a System (a reusable, self-describing subsystem/area) in a project.

    Associate records with it via the `system_ids` arg on create/update_task and
    create/update_note.

    Create one the moment two records would share an area that has no System
    yet — the same two-or-more test snippets use. Don't wait to be asked to
    name an area that plainly exists in the code, and don't route the
    creation through operator approval — minting vocabulary is the agent's
    call (#2798); an unmodelled area means every record about it stays
    untaggable. An audit or sweep that walks the codebase is a DISCOVERY
    moment: mint the Systems it names as it names them — the duplicate gate
    below, plus reviewing the existing list, is what guards against sprawl,
    not holding back. Prefer the standard cross-project names where the area
    fits one (CI & Release, Auth & Access, Data Model & Storage, API Surface,
    UI & Design, Import & Export, Background Jobs, Observability) so the same
    word means the same thing in every project. Give each one a one-paragraph
    charter, not just a label: the description is what tells a later session
    whether a record belongs here.

    Args:
        project_id: The project this system belongs to (required).
        name: Short label (required).
        description: What the system is and how it's used — a name is rarely enough.
        color: Optional UI accent (hex), or empty.

    Duplicate-gated like the other creates: if a System with the same
    normalized name already exists in this project (archived included), the
    call returns {"duplicate": true, "existing_id": ...} instead of creating —
    tag records to that one, or update_system it if its charter needs work.
    """
    uid = current_user_id()
    norm = " ".join(name.split()).lower()
    if norm:
        try:
            existing = await systems_svc.list_systems(
                uid, project_id, include_archived=True
            )
        except Exception:
            existing = []
        for s in existing:
            if " ".join(s.name.split()).lower() == norm:
                return {
                    "duplicate": True,
                    "existing_id": s.id,
                    "message": (
                        f"System '{s.name}' (#{s.id}) already covers this area "
                        "in this project. Tag records to it with system_ids, "
                        "or update_system it if the charter needs revising — "
                        "a second System with the same name would split the "
                        "area's records across two piles."
                    ),
                }
    system = await systems_svc.create_system(
        uid, project_id=project_id, name=name,
        description=description or None, color=color or None,
    )
    if system is None:
        raise ValueError(f"cannot create system in project {project_id} (no write access)")
    return system.to_dict()


async def list_systems(project_id: int, include_archived: bool = False) -> dict:
    """List a project's systems (active by default), ordered by order_index.

    Pass include_archived=True to include archived systems.
    """
    uid = current_user_id()
    rows = await systems_svc.list_systems(uid, project_id, include_archived=include_archived)
    return {"systems": [s.to_dict() for s in rows]}


async def get_system(system_id: int) -> dict:
    """Fetch a System plus the records associated with it.

    Returns the system, plus its associated records split into `issues`,
    `tasks` (work/plan), and `notes`.
    """
    uid = current_user_id()
    system = await systems_svc.get_system(uid, system_id)
    if system is None:
        raise ValueError(f"system {system_id} not found")
    records = await systems_svc.list_records_for_system(uid, system_id)
    issues, tasks, notes = [], [], []
    for r in records:
        d = r.to_dict()
        if r.status is None:
            notes.append(d)
        elif r.task_kind == "issue":
            issues.append(d)
        else:
            tasks.append(d)
    data = system.to_dict()
    data["issues"] = issues
    data["tasks"] = tasks
    data["notes"] = notes
    return data


async def update_system(
    system_id: int,
    name: str = "",
    description: str = "",
    color: str = "",
    status: str = "",
    order_index: int = -1,
) -> dict:
    """Update a System. Only explicitly provided fields change.

    Args:
        status: 'active' or 'archived'. Archive a system to retire it without
            losing history; archived systems hide from default lists.
        order_index: display position (0-based); -1 = leave unchanged.
    """
    uid = current_user_id()
    fields: dict = {}
    if name:
        fields["name"] = name
    if description:
        fields["description"] = description
    if color:
        fields["color"] = color
    if status:
        fields["status"] = status
    if order_index >= 0:
        fields["order_index"] = order_index
    system = await systems_svc.update_system(uid, system_id, **fields)
    if system is None:
        raise ValueError(f"system {system_id} not found or no write access")
    return system.to_dict()


async def list_system_records(
    system_id: int, kind: str = "", open_only: bool = False
) -> dict:
    """Everything filed under one System — the way to READ a subsystem.

    Reach for this when investigating a specific area: it returns the notes,
    tasks, issues and snippets someone deliberately tagged to it — the
    subsystem's accumulated record, unranked. Start with its reference note if
    one exists (titled "«System» — reference"); that is the living state, and
    the rest is history and open work around it. For a ranked cut of the same
    slice, search(system_id=...) filters semantic search to this association.

    Args:
        kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
        open_only: limit to tasks not done/cancelled (e.g. open issues only).
    """
    uid = current_user_id()
    rows = await systems_svc.list_records_for_system(
        uid, system_id, kind=kind or None, open_only=open_only,
    )
    return {"records": [r.to_dict() for r in rows]}


async def delete_system(system_id: int) -> dict:
    """Soft-delete a System (recoverable). Its record associations are removed."""
    uid = current_user_id()
    ok = await systems_svc.delete_system(uid, system_id)
    if not ok:
        raise ValueError(f"system {system_id} not found or no write access")
    return {"message": f"System {system_id} deleted."}


def register(mcp) -> None:
    for fn in (
        create_system,
        list_systems,
        get_system,
        update_system,
        list_system_records,
        delete_system,
    ):
        mcp.tool(name=fn.__name__)(fn)
