"""REST routes for snippets — reusable functions/components recorded for recall.

A snippet is a note with note_type='snippet' (see services/snippets.py). These
routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the
agent-facing surface. Both go through services/snippets.py, so the
serialize/parse contract and embedding-on-create live in one place (DRY).

ACL (rule #78): reads/writes of a single snippet resolve through the share-aware
`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so
a shared editor isn't rejected by the owner-scoped service — mirroring
routes/notes.py. The list is owner-scoped, matching the note-browse surface.
"""
import logging

from quart import Blueprint, jsonify, request

from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import dedup as dedup_svc
from scribe.services import snippets as snippets_svc
from scribe.services import systems as systems_svc
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
from scribe.services.access import (
    can_write_note,
    describe_provenance,
    label_shared_items,
)
from scribe.services.notes import get_note_for_user

logger = logging.getLogger(__name__)

snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets")

# Fields the create/update payload may carry, mapped straight to the service.
_STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol")


async def _load_snippet(uid: int, snippet_id: int):
    """Share-aware resolve of a snippet by id → (note, permission) or None if it
    isn't accessible or isn't a snippet."""
    result = await get_note_for_user(uid, snippet_id)
    if result is None:
        return None
    note, permission = result
    if note.note_type != snippets_svc.SNIPPET_NOTE_TYPE:
        return None
    return note, permission


@snippets_bp.route("", methods=["GET"])
@login_required
async def list_snippets_route():
    uid = get_current_user_id()
    q = request.args.get("q") or None
    tag = request.args.get("tag", "")
    try:
        project_id = int(request.args.get("project_id", 0) or 0) or None
    except (TypeError, ValueError):
        project_id = None
    # Reverse lookup — "what already lives here?" Empty args are ignored, so the
    # plain list is unchanged.
    repo = request.args.get("repo", "")
    path = request.args.get("path", "")
    symbol = request.args.get("symbol", "")
    # Drift check (#2086). "attention" is what the UI's filter chip sends.
    verification = request.args.get("verification", "")
    limit, offset = parse_pagination()
    items, total = await snippets_svc.list_snippets(
        uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id,
        repo=repo, path=path, symbol=symbol, verification=verification,
    )
    # Mark rows owned by someone else so the UI can show whose they are — an
    # unmarked row in your own list reads as one you recorded and vetted.
    items = await label_shared_items(uid, items)
    # One aggregate for the whole page — a per-row lookup here would be N+1 by
    # construction. Every row gets the key, zero-filled, so the UI renders
    # "never pulled" rather than having to treat a missing field as a state.
    usage = await usage_for_notes([int(it["id"]) for it in items])
    for it in items:
        it["usage"] = usage.get(int(it["id"]), empty_usage())
    return jsonify({"snippets": items, "total": total})


@snippets_bp.route("", methods=["POST"])
@login_required
async def create_snippet_route():
    uid = get_current_user_id()
    data = await request.get_json() or {}
    name = (data.get("name") or "").strip()
    code = (data.get("code") or "").strip()
    if not name or not code:
        return jsonify({"error": "name and code are required"}), 400
    project_id = data.get("project_id") or None

    # Same near-duplicate gate the MCP create path applies: recording the same
    # reusable thing twice is what merge then has to undo, so catch it here too.
    # `force` is the deliberate override once the operator has seen the warning.
    if not data.get("force"):
        dup = await dedup_svc.find_duplicate_note(
            uid,
            snippets_svc.compose_title(name, data.get("when_to_use", "")),
            snippets_svc.compose_body(
                code=data.get("code", ""),
                language=data.get("language", ""),
                signature=data.get("signature", ""),
                when_to_use=data.get("when_to_use", ""),
                repo=data.get("repo", ""),
                path=data.get("path", ""),
                symbol=data.get("symbol", ""),
                locations=data.get("locations"),
            ),
            project_id=project_id,
            is_task=False,
            note_type=snippets_svc.SNIPPET_NOTE_TYPE,
            # Matched on the artefact — location and code — before prose, the
            # same way the MCP create path does (#2518). Both surfaces must
            # apply the identical gate or the web UI becomes the way to record
            # a duplicate the agent would have been stopped from writing.
            code=data.get("code", ""),
            locations=snippets_svc.resolve_locations(
                data.get("repo", ""), data.get("path", ""), data.get("symbol", ""),
                data.get("locations"),
            ),
        )
        if dup is not None:
            return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409

    note = await snippets_svc.create_snippet(
        uid,
        name=name,
        code=data.get("code", ""),
        language=data.get("language", ""),
        signature=data.get("signature", ""),
        when_to_use=data.get("when_to_use", ""),
        repo=data.get("repo", ""),
        path=data.get("path", ""),
        symbol=data.get("symbol", ""),
        locations=data.get("locations"),
        tags=data.get("tags"),
        project_id=project_id,
    )
    if data.get("system_ids") is not None:
        await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
    out = snippets_svc.snippet_to_dict(note)
    out["systems"] = [
        s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
    ]
    return jsonify(out), 201


@snippets_bp.route("/<int:snippet_id>", methods=["GET"])
@login_required
async def get_snippet_route(snippet_id: int):
    uid = get_current_user_id()
    loaded = await _load_snippet(uid, snippet_id)
    if loaded is None:
        return not_found("Snippet")
    note, permission = loaded
    data = snippets_svc.snippet_to_dict(note)
    # Forge-checked freshness (#2690) — same decoration the MCP pull gets.
    await snippets_svc.attach_live_body(note, data)
    data["permission"] = permission
    # Read the association as the OWNER: a shared reader isn't scoped to the
    # owner's project, so their own id would come back empty (mirrors the
    # write-as-owner pattern this module already uses).
    data["systems"] = [
        s.to_dict()
        for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
    ]
    data.update(await describe_provenance(uid, note))
    data["usage"] = (await usage_for_notes([snippet_id])).get(
        snippet_id, empty_usage()
    )
    # Opening the detail view IS a pull — the operator chose to look. Tagged
    # apart from the MCP sources so "the agent reused it" and "a human read it"
    # stay distinguishable; they mean different things for pruning (#2085).
    record_pulled(user_id=uid, note_id=snippet_id, source="rest_snippet")
    return jsonify(data)


@snippets_bp.route("/<int:snippet_id>", methods=["PATCH"])
@login_required
async def update_snippet_route(snippet_id: int):
    uid = get_current_user_id()
    loaded = await _load_snippet(uid, snippet_id)
    if loaded is None:
        return not_found("Snippet")
    note, _ = loaded
    if not await can_write_note(uid, snippet_id):
        return jsonify({"error": "Permission denied"}), 403
    owner_uid = note.user_id
    data = await request.get_json() or {}

    # Partial update: only keys present in the payload change (the service
    # treats None as "leave unchanged"). Empty strings ARE applied — the form
    # sends the full field set, so a cleared field is an intentional clear.
    kwargs = {k: data[k] for k in _STR_FIELDS if k in data}
    if "locations" in data:
        kwargs["locations"] = data["locations"]
    if "tags" in data:
        kwargs["tags"] = data["tags"]
    if "project_id" in data:
        # A present-but-empty project_id is a deliberate detach, not "unchanged"
        # — the service distinguishes the two via its UNSET sentinel.
        kwargs["project_id"] = data["project_id"] or None

    updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
    if updated is None:
        return not_found("Snippet")
    if data.get("system_ids") is not None:
        await systems_svc.set_record_systems(owner_uid, snippet_id, data["system_ids"])
    out = snippets_svc.snippet_to_dict(updated)
    out["systems"] = [
        s.to_dict() for s in await systems_svc.list_record_systems(owner_uid, snippet_id)
    ]
    return jsonify(out)


@snippets_bp.route("/<int:snippet_id>/unmerge", methods=["POST"])
@login_required
async def unmerge_snippet_route(snippet_id: int):
    """Pull one source back out of a merged survivor. Body: {"source_id": int}.

    Also the repair for a half-undone merge: restoring a source from the trash by
    hand leaves the survivor still claiming its call sites, and running this on an
    already-restored source strips them."""
    uid = get_current_user_id()
    data = await request.get_json() or {}
    source_id = data.get("source_id")
    if not isinstance(source_id, int):
        return jsonify({"error": "source_id (int) is required"}), 400
    try:
        result = await snippets_svc.unmerge_snippet(uid, snippet_id, source_id)
    except PermissionError as exc:
        return jsonify({"error": str(exc)}), 403
    except snippets_svc.UnmergeError as exc:
        # 409, not 400: the request is well-formed, the record's state is what
        # makes it impossible — and the message says which.
        return jsonify({"error": str(exc)}), 409
    if result is None:
        return not_found("Snippet")
    survivor, restored = result
    return jsonify({
        "survivor": snippets_svc.snippet_to_dict(survivor),
        "restored": snippets_svc.snippet_to_dict(restored) if restored else None,
    })


@snippets_bp.route("/duplicates", methods=["GET"])
@login_required
async def duplicate_snippets_route():
    """Near-duplicate snippets already recorded, grouped into merge candidates.

    Registered ABOVE the `/<int:snippet_id>` routes on purpose — Quart matches
    an int converter before a static segment either way, but keeping the literal
    path first makes the precedence obvious to the next person reading this."""
    uid = get_current_user_id()
    try:
        threshold = float(request.args.get("threshold", 0) or 0)
    except (TypeError, ValueError):
        threshold = 0.0
    return jsonify(await dedup_svc.find_duplicate_snippets(
        uid, threshold=threshold if threshold > 0 else None
    ))


@snippets_bp.route("/<int:snippet_id>/verify", methods=["POST"])
@login_required
async def verify_snippet_route(snippet_id: int):
    """Record a drift-check verdict. Body: {"status": ..., "detail", "path"}.

    The CHECK itself runs wherever the code is — an agent with the working tree
    — because Scribe has no checkout and shouldn't have one. This endpoint just
    stores what was found. It's here for parity with the MCP tool and so the UI
    can clear a stale marker after the operator fixes a record by hand."""
    uid = get_current_user_id()
    if await _load_snippet(uid, snippet_id) is None:
        return not_found("Snippet")
    # Distinguished from not-found deliberately: the service returns None for
    # both, and telling a shared reader "no such snippet" about one they can
    # plainly see is a confusing lie.
    if not await can_write_note(uid, snippet_id):
        return jsonify({"error": "Permission denied"}), 403

    data = await request.get_json() or {}
    status = (data.get("status") or "").strip()
    if not status:
        return jsonify({"error": "status is required"}), 400
    try:
        updated = await snippets_svc.record_verification(
            uid, snippet_id,
            status=status,
            detail=data.get("detail") or "",
            path=data.get("path") or "",
        )
    except ValueError as exc:
        # An unknown status — reject it rather than storing a value the filter
        # would then never match.
        return jsonify({"error": str(exc)}), 400
    if updated is None:
        return not_found("Snippet")
    return jsonify(snippets_svc.snippet_to_dict(updated))


@snippets_bp.route("/<int:snippet_id>/merge", methods=["POST"])
@login_required
async def merge_snippet_route(snippet_id: int):
    """Unify source snippets into this one (the canonical target). Body:
    {"source_ids": [int, ...]}. Requires write on the target and every source,
    and all must share the target's owner (cross-owner merge is out of scope)."""
    uid = get_current_user_id()
    loaded = await _load_snippet(uid, snippet_id)
    if loaded is None:
        return not_found("Snippet")
    target, _ = loaded
    if not await can_write_note(uid, snippet_id):
        return jsonify({"error": "Permission denied"}), 403

    data = await request.get_json() or {}
    raw = data.get("source_ids") or []
    source_ids = [s for s in raw if isinstance(s, int) and s != snippet_id]
    if not source_ids:
        return jsonify({"error": "source_ids (non-empty list of other snippet ids) is required"}), 400

    owner_uid = target.user_id
    for sid in source_ids:
        sloaded = await _load_snippet(uid, sid)
        if sloaded is None:
            return not_found(f"Snippet {sid}")
        snote, _ = sloaded
        if snote.user_id != owner_uid:
            return jsonify({"error": "can only merge snippets with the same owner"}), 400
        if not await can_write_note(uid, sid):
            return jsonify({"error": f"Permission denied for snippet {sid}"}), 403

    result = await snippets_svc.merge_snippets(owner_uid, snippet_id, source_ids)
    if result is None:
        return not_found("Snippet")
    note, merged_ids = result
    out = snippets_svc.snippet_to_dict(note)
    out["merged_ids"] = merged_ids
    return jsonify(out)


@snippets_bp.route("/<int:snippet_id>", methods=["DELETE"])
@login_required
async def delete_snippet_route(snippet_id: int):
    uid = get_current_user_id()
    loaded = await _load_snippet(uid, snippet_id)
    if loaded is None:
        return not_found("Snippet")
    note, _ = loaded
    if not await can_write_note(uid, snippet_id):
        return jsonify({"error": "Permission denied"}), 403
    if not await snippets_svc.delete_snippet(note.user_id, snippet_id):
        return not_found("Snippet")
    return "", 204
