"""
Selvedge MCP Server.

Exposes 7 tools that AI coding agents call to track and query codebase changes:
  - log_change     : record a change event (incl. dual-event renames)
  - diff           : get change history for an entity
  - blame          : get the most recent change + context for an entity
  - history        : filtered history across all entities
  - changeset      : retrieve all events in a named feature/task group
  - search         : full-text search across all events
  - prior_attempts : prior change attempts on an entity + inferred outcome

Convention notes for v0.3.3+:
  - All tool parameters use Annotated[T, Field(description=...)] so agents
    see per-parameter docs in the JSON-RPC tools/list response (and so
    Smithery / Glama / PulseMCP score them correctly).
  - Each tool declares MCP annotations (readOnlyHint, destructiveHint,
    idempotentHint, openWorldHint, title) so clients can surface or gate
    the tools appropriately. log_change is the only writer; the rest are
    read-only + idempotent.
  - log_change and blame return TypedDicts so an outputSchema is
    generated by FastMCP, matching what diff/history/changeset/search
    already get from their list[dict] returns.
  - Application-level "no result" cases (blame on an unknown entity,
    changeset with no events) intentionally return ``{"error": "..."}``
    with protocol-level ``isError: false``. Empty history is not a
    protocol failure; the error key in the payload is the documented
    convention so callers know how to recognize it without parsing
    string messages.
"""

import inspect
import json
from typing import Annotated, TypedDict

from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import Field

from .config import get_db_path
from .logging_config import configure_logging
from .models import ChangeEvent
from .storage import SelvedgeStorage
from .timeutil import parse_time_string
from .validation import check_entity_path_shape, check_reasoning_quality

mcp = FastMCP(
    "selvedge",
    instructions=(
        "Selvedge captures the WHY behind every change to a codebase — written "
        "live by the agent making the change, before the session context "
        "evaporates. The diff is git's job; the reasoning is Selvedge's.\n"
        "\n"
        "WHEN TO CALL log_change: immediately after you add, remove, or modify "
        "a meaningful semantic entity — a DB column, table, function, class, "
        "API endpoint, file, dependency, env var, config flag, or index. One "
        "log_change per entity. Don't batch.\n"
        "\n"
        "WHAT GOES IN reasoning: the user's original ask, the constraint or "
        "bug that prompted the change, and any decision that wouldn't be "
        "obvious from the diff in six months. 'User asked to add 2FA — needs "
        "phone for SMS verification' is good. 'user request', 'done', 'fix' "
        "are flagged by the quality validator as low-signal placeholders.\n"
        "\n"
        "GROUP related changes with the same changeset_id (a short slug like "
        "'add-stripe-billing'). Pass the same id to every log_change for that "
        "feature so they can later be retrieved together via the changeset "
        "tool.\n"
        "\n"
        "BEFORE EDITING an entity, call prior_attempts on it — if the same "
        "change was tried before and reverted, you'll see why and can change "
        "your plan instead of repeating a rejected approach.\n"
        "\n"
        "WHEN TO READ:\n"
        "  - prior_attempts — 'was this entity tried-and-rejected before? why?'\n"
        "  - blame          — 'who/why for THIS entity, most recent change'\n"
        "  - diff           — 'full history for THIS entity, newest first'\n"
        "  - history        — 'everything in the last N days/across a project'\n"
        "  - changeset      — 'all changes that belong to THIS feature'\n"
        "  - search         — 'anything mentioning these words'\n"
    ),
)

_storage: SelvedgeStorage | None = None


def get_storage() -> SelvedgeStorage:
    global _storage
    if _storage is None:
        _storage = SelvedgeStorage(get_db_path())
    return _storage


# ---------------------------------------------------------------------------
# Return shapes
#
# These TypedDicts give FastMCP something to generate ``outputSchema`` from.
# Every field is always populated in the response payload (with empty
# strings, empty list, or empty dict when "absent") because FastMCP's
# output validator rejects null where the schema says "string"/"array".
# ---------------------------------------------------------------------------


class LogChangeResult(TypedDict):
    """Result of a log_change call. All fields are always present.

    On success: ``status`` == "logged", ``id`` and ``timestamp`` populated,
    ``error`` empty, ``warnings`` may be a non-empty list if the reasoning
    quality validator flagged the input.

    On validation failure: ``status`` == "error", ``error`` populated,
    ``id`` and ``timestamp`` empty, ``warnings`` empty.
    """

    id: str
    timestamp: str
    status: str
    error: str
    warnings: list[str]


class BlameResult(TypedDict):
    """The most recent change for an entity, or an error payload.

    On success: every event field is populated, ``error`` is the empty
    string. On miss: every event field is empty, ``error`` carries the
    "no history found" message.
    """

    id: str
    timestamp: str
    entity_type: str
    entity_path: str
    change_type: str
    diff: str
    reasoning: str
    agent: str
    session_id: str
    git_commit: str
    project: str
    changeset_id: str
    metadata: dict
    error: str


_EMPTY_BLAME: BlameResult = {
    "id": "",
    "timestamp": "",
    "entity_type": "",
    "entity_path": "",
    "change_type": "",
    "diff": "",
    "reasoning": "",
    "agent": "",
    "session_id": "",
    "git_commit": "",
    "project": "",
    "changeset_id": "",
    "metadata": {},
    "error": "",
}


# ---------------------------------------------------------------------------
# Tool annotations (MCP standard) — declared as constants so all five
# read-only tools share the same shape and the writer's intent is explicit.
#
# Constructed as ``ToolAnnotations`` (the official MCP Python type)
# rather than plain dicts. Both shapes work at runtime — Pydantic
# accepts either — but the typed object is what FastMCP's ``@tool``
# decorator declares in its signature, so plain dicts trip mypy in the
# CI lint job.
# ---------------------------------------------------------------------------


_READ_ANNOTATIONS = ToolAnnotations(
    readOnlyHint=True,
    destructiveHint=False,
    idempotentHint=True,
    openWorldHint=False,
)

_WRITE_ANNOTATIONS = ToolAnnotations(
    readOnlyHint=False,
    # log_change is append-only — it never modifies or removes existing
    # rows, only adds new ones. So destructiveHint is False.
    destructiveHint=False,
    # Each call mints a new event with a new UUID, so calling twice is
    # NOT idempotent.
    idempotentHint=False,
    openWorldHint=False,
)


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------


@mcp.tool(
    title="Log a code change",
    annotations=_WRITE_ANNOTATIONS,
)
def log_change(
    entity_path: Annotated[
        str,
        Field(
            description=(
                "Dot/slash-notation path to the entity. Required and non-empty. "
                "Examples: 'users.email' (DB column), 'users' (DB table), "
                "'src/auth.py::login' (function in file), 'src/auth.py' (file), "
                "'api/v1/users' (API route), 'deps/stripe' (dependency), "
                "'env/STRIPE_SECRET_KEY' (env variable)."
            ),
        ),
    ],
    change_type: Annotated[
        str,
        Field(
            description=(
                "What kind of change. One of: add, remove, modify, rename, retype, "
                "create, delete, index_add, index_remove, migrate. "
                "Invalid values are rejected — pick the closest match."
            ),
        ),
    ],
    diff: Annotated[
        str,
        Field(
            default="",
            description=(
                "The actual change — SQL migration text, code diff, or a "
                "human-readable description of what changed. Optional but "
                "strongly recommended for non-trivial changes."
            ),
        ),
    ] = "",
    entity_type: Annotated[
        str,
        Field(
            default="other",
            description=(
                "Category of entity. One of: column, table, file, function, class, "
                "endpoint, dependency, env_var, index, schema, config, other. "
                "Unknown values are coerced to 'other'."
            ),
        ),
    ] = "other",
    reasoning: Annotated[
        str,
        Field(
            default="",
            description=(
                "Why the change was made. Include the user's original request, "
                "the problem being solved, or any context that won't be obvious "
                "from the diff alone. Good example: 'User asked to add 2FA — "
                "needs phone number to send SMS verification codes.' Avoid "
                "generic placeholders like 'user request' or 'done' — these "
                "are flagged by the quality validator and returned in `warnings`."
            ),
        ),
    ] = "",
    agent: Annotated[
        str,
        Field(
            default="",
            description=(
                "Name/ID of the AI agent making the change "
                "(e.g. 'claude-code', 'cursor', 'copilot', 'human')."
            ),
        ),
    ] = "",
    session_id: Annotated[
        str,
        Field(
            default="",
            description="The agent session or conversation ID, if available.",
        ),
    ] = "",
    git_commit: Annotated[
        str,
        Field(
            default="",
            description=(
                "The git commit hash this change will land in. Can be backfilled "
                "later via `selvedge backfill-commit` or the post-commit hook."
            ),
        ),
    ] = "",
    project: Annotated[
        str,
        Field(
            default="",
            description="Repository or project name. Useful when one DB tracks multiple projects.",
        ),
    ] = "",
    changeset_id: Annotated[
        str,
        Field(
            default="",
            description=(
                "Optional grouping ID for related changes that belong to the same "
                "feature or task. Use a short slug like 'add-stripe-billing'. All "
                "events sharing a changeset_id can be queried together via the "
                "`changeset` tool."
            ),
        ),
    ] = "",
    rename_from: Annotated[
        str,
        Field(
            default="",
            description=(
                "The entity's previous path, when this change is a rename. Set it "
                "together with change_type='rename' and put the NEW path in "
                "entity_path. Selvedge records the dual-event rename pattern: a "
                "'rename' event on the old path and a 'create' event on the new "
                "path whose metadata.renamed_from points back to the old one, so "
                "blame/diff/prior_attempts on the new path still see the history. "
                "Leave empty for any non-rename change."
            ),
        ),
    ] = "",
) -> LogChangeResult:
    """Record a change to a codebase entity.

    Call this immediately after making any meaningful change. The event is
    written to the local SQLite store and returned with its assigned id and
    timestamp. If the reasoning fails the quality validator (empty, too
    short, or a generic placeholder), or the entity_path doesn't match the
    usual shape for its entity_type, the result includes a `warnings`
    array — the event is still stored.

    Renames: pass the new path in `entity_path`, set `change_type="rename"`,
    and pass the old path in `rename_from`. Selvedge then writes two events —
    a `rename` on the old path and a `create` on the new path with
    `metadata.renamed_from` set — so the entity's history follows it. Example:

        log_change(
            entity_path="src/auth/session.py::login",   # new path
            change_type="rename",
            rename_from="src/auth.py::login",            # old path
            entity_type="function",
            reasoning="Split auth.py into an auth/ package; login moved.",
        )

    On validation failure (invalid change_type, missing entity_path, or
    `rename_from` set without change_type='rename') the result is
    `{"status": "error", "error": "..."}` with no event written.
    """
    storage = get_storage()
    storage.record_tool_call("log_change", entity_path=entity_path, agent=agent)

    if rename_from and change_type != "rename":
        return {
            "id": "",
            "timestamp": "",
            "status": "error",
            "error": "rename_from is only valid with change_type='rename'",
            "warnings": [],
        }

    try:
        if rename_from:
            # Dual-event rename: rename(old) + create(new, metadata.renamed_from).
            # The create-on-new-path event is the surviving entity, so it's
            # the one reported back as the result.
            events = storage.log_rename(
                old_path=rename_from,
                new_path=entity_path,
                entity_type=entity_type,
                diff=diff,
                reasoning=reasoning,
                agent=agent,
                session_id=session_id,
                git_commit=git_commit,
                project=project,
                changeset_id=changeset_id,
            )
            stored = events[-1]
        else:
            event = ChangeEvent(
                entity_path=entity_path,
                change_type=change_type,
                diff=diff,
                entity_type=entity_type,
                reasoning=reasoning,
                agent=agent,
                session_id=session_id,
                git_commit=git_commit,
                project=project,
                changeset_id=changeset_id,
            )
            stored = storage.log_event(event)
    except ValueError as e:
        return {
            "id": "",
            "timestamp": "",
            "status": "error",
            "error": str(e),
            "warnings": [],
        }

    warnings = check_reasoning_quality(reasoning)
    warnings += check_entity_path_shape(stored.entity_path, stored.entity_type)
    return {
        "id": stored.id,
        "timestamp": stored.timestamp,
        "status": "logged",
        "error": "",
        "warnings": warnings,
    }


@mcp.tool(
    title="Diff an entity's history",
    annotations=_READ_ANNOTATIONS,
)
def diff(
    entity_path: Annotated[
        str,
        Field(
            description=(
                "Entity path or path prefix. Prefix matching is supported: "
                "'users' returns history for the users table AND all its columns "
                "('users.email', 'users.created_at', etc.). Use a more specific "
                "path to narrow the result."
            ),
        ),
    ],
    limit: Annotated[
        int,
        Field(default=20, ge=1, description="Maximum number of events to return."),
    ] = 20,
) -> list[dict]:
    """Get change history for a codebase entity, newest first.

    Supports prefix matching — e.g. 'users' returns all events for the users
    table and any users.* column.
    """
    storage = get_storage()
    storage.record_tool_call("diff", entity_path=entity_path)
    return storage.get_entity_history(entity_path, limit)


@mcp.tool(
    title="Blame an entity",
    annotations=_READ_ANNOTATIONS,
)
def blame(
    entity_path: Annotated[
        str,
        Field(
            description=(
                "Exact entity path (no prefix matching). Examples: 'users.email', "
                "'src/auth.py::login', 'env/STRIPE_SECRET_KEY'."
            ),
        ),
    ],
) -> BlameResult:
    """Most recent change to an entity — what changed, when, who, why.

    Like `git blame` but for semantic entities (DB columns, functions, env
    vars, dependencies) and AI agents. If no history exists for the entity,
    returns `{"error": "..."}` with protocol-level `isError: false`.
    """
    storage = get_storage()
    storage.record_tool_call("blame", entity_path=entity_path)
    result = storage.get_blame(entity_path)
    if not result:
        miss = dict(_EMPTY_BLAME)
        miss["error"] = f"No history found for '{entity_path}'"
        return miss  # type: ignore[return-value]
    # Merge over the empty template so every schema field is populated
    # even if the storage layer returns a slimmer dict.
    populated = dict(_EMPTY_BLAME)
    populated.update(result)
    # Storage returns metadata as a JSON string; the schema declares it
    # as an object. Parse it so the caller gets a dict, with a graceful
    # fallback to empty-dict on malformed data.
    md = populated.get("metadata", "")
    if isinstance(md, str):
        try:
            populated["metadata"] = json.loads(md) if md else {}
        except json.JSONDecodeError:
            populated["metadata"] = {}
    populated["error"] = ""
    return populated  # type: ignore[return-value]


@mcp.tool(
    title="Browse history",
    annotations=_READ_ANNOTATIONS,
)
def history(
    since: Annotated[
        str,
        Field(
            default="",
            description=(
                "Time window — ISO 8601 datetime OR relative shorthand: "
                "'15m' (last 15 minutes), '24h' (last 24 hours), '7d' (last 7 days), "
                "'5mo' (last 5 months), '1y' (last year). 'm' means minutes; "
                "'mo' or 'mon' means months. Unparseable values produce an error "
                "rather than silently returning empty results. Empty = all time."
            ),
        ),
    ] = "",
    entity_path: Annotated[
        str,
        Field(default="", description="Filter to a specific entity or path prefix."),
    ] = "",
    project: Annotated[
        str,
        Field(default="", description="Filter to a specific project/repository."),
    ] = "",
    changeset_id: Annotated[
        str,
        Field(default="", description="Filter to a specific changeset (feature/task group)."),
    ] = "",
    limit: Annotated[
        int,
        Field(default=50, ge=1, description="Maximum number of results."),
    ] = 50,
) -> list[dict]:
    """Filtered change history across all entities, newest first.

    Combine `since`, `entity_path`, `project`, and `changeset_id` to scope
    the result. On unparseable `since` input the response is
    `[{"error": "..."}]` so the caller sees the problem.
    """
    storage = get_storage()
    storage.record_tool_call("history", entity_path=entity_path)
    if since:
        try:
            resolved_since = parse_time_string(since)
        except ValueError as e:
            return [{"error": str(e)}]
    else:
        resolved_since = ""
    return storage.get_history(
        since=resolved_since,
        entity_path=entity_path,
        project=project,
        changeset_id=changeset_id,
        limit=limit,
    )


@mcp.tool(
    title="Get a changeset",
    annotations=_READ_ANNOTATIONS,
)
def changeset(
    changeset_id: Annotated[
        str,
        Field(
            description=(
                "The changeset identifier (the same slug or UUID passed to "
                "`log_change`'s changeset_id parameter). Examples: "
                "'add-stripe-billing', 'fix-auth-redirect'."
            ),
        ),
    ],
) -> list[dict]:
    """All events that share a `changeset_id`, oldest first.

    Use to reconstruct the full scope of a feature or task across multiple
    entities. If the changeset has no events, returns
    `[{"error": "..."}]` so the caller can distinguish "unknown changeset"
    from "empty history."
    """
    storage = get_storage()
    storage.record_tool_call("changeset")
    events = storage.get_changeset(changeset_id)
    if not events:
        return [{"error": f"No events found for changeset '{changeset_id}'"}]
    return events


@mcp.tool(
    title="Search events",
    annotations=_READ_ANNOTATIONS,
)
def search(
    query: Annotated[
        str,
        Field(
            description=(
                "Search string (case-insensitive substring). Searches across "
                "entity_path, diff, reasoning, and agent fields. SQL LIKE "
                "wildcards (`_` and `%`) are escaped, so 'stripe_customer_id' "
                "matches the literal underscore rather than any single char."
            ),
        ),
    ],
    limit: Annotated[
        int,
        Field(default=20, ge=1, description="Maximum number of results."),
    ] = 20,
) -> list[dict]:
    """Full-text search across entity paths, diffs, reasoning, and agents.

    Useful for questions like 'what changes were made for the billing
    feature?', 'which columns were added by cursor?', or 'show everything
    related to authentication'.
    """
    storage = get_storage()
    storage.record_tool_call("search")
    return storage.search(query, limit)


@mcp.tool(
    title="Prior attempts on an entity",
    annotations=_READ_ANNOTATIONS,
)
def prior_attempts(
    entity_path: Annotated[
        str,
        Field(
            default="",
            description=(
                "The entity you're about to change. Exact path with prefix "
                "matching — 'users' also covers 'users.email'. Examples: "
                "'src/auth.py::login', 'users.email', 'env/STRIPE_SECRET_KEY'. "
                "Provide this OR `description`."
            ),
        ),
    ] = "",
    description: Annotated[
        str,
        Field(
            default="",
            description=(
                "Free-text description of what you're about to do, when you don't "
                "have an exact entity_path. Matched as a substring against prior "
                "reasoning, diffs, and entity paths. Provide this OR `entity_path` "
                "(entity_path takes precedence if both are given)."
            ),
        ),
    ] = "",
    min_confidence: Annotated[
        str,
        Field(
            default="proximity_high",
            description=(
                "Confidence floor. 'proximity_high' (default) returns only "
                "attempts that were clearly tried and then reverted within the "
                "window — the high-signal 'rejected before' cases. Pass "
                "'proximity_low' to also see the noisy tail (still-active changes "
                "and far-apart reverts)."
            ),
        ),
    ] = "proximity_high",
    window_minutes: Annotated[
        int,
        Field(
            default=10080,
            ge=1,
            description=(
                "Proximity window in minutes for the add->remove revert "
                "heuristic. An attempt removed within this many minutes is "
                "'proximity_high'; beyond it, 'proximity_low'. Default 10080 "
                "(7 days)."
            ),
        ),
    ] = 10080,
    limit: Annotated[
        int,
        Field(default=20, ge=1, description="Maximum number of results."),
    ] = 20,
) -> list[dict]:
    """Prior change attempts on an entity, each with an inferred outcome.

    Call this BEFORE editing an entity. If the same change was tried before
    and reverted, you get the prior `reasoning` and `change_type` plus an
    inferred `outcome` — so you can change your plan instead of repeating a
    rejected approach.

    Each result is a change event with three extra fields:

      - `outcome`           — "reverted" (a later remove/delete on the same
                              path) or "active" (no later removal seen).
      - `confidence`        — "proximity_high" or "proximity_low".
      - `outcome_reasoning` — the reverting event's reasoning (WHY it was
                              rejected), or "" while still active.

    Outcome is inferred from add->remove proximity — v0.3.7 has no explicit
    reject/revert change types yet (those arrive in v0.3.11). Output is
    templated, deterministic, and makes no LLM call. This is a pull-only
    tool: it never writes and never pushes; you decide when to ask.

    Conservative by design — `min_confidence` defaults to "proximity_high",
    so an empty list (nothing clearly tried-and-rejected) is the normal,
    preferred answer over a speculative false positive. Pass
    `min_confidence="proximity_low"` to widen recall.
    """
    storage = get_storage()
    storage.record_tool_call("prior_attempts", entity_path=entity_path)
    if not entity_path and not description:
        return [{"error": "prior_attempts requires either entity_path or description"}]
    if min_confidence not in ("proximity_high", "proximity_low"):
        return [
            {"error": "min_confidence must be 'proximity_high' or 'proximity_low'"}
        ]
    return storage.get_prior_attempts(
        entity_path=entity_path,
        query=description,
        min_confidence=min_confidence,
        window_minutes=window_minutes,
        limit=limit,
    )


# ---------------------------------------------------------------------------
# Apply inspect.cleandoc to every registered tool's docstring so the
# JSON-RPC `description` field doesn't leak the function-body indent.
# This runs once at import time after the @mcp.tool() decorators have
# attached metadata. Cheap to do here; avoids touching FastMCP internals.
# ---------------------------------------------------------------------------


def _tighten_descriptions() -> None:
    """Strip leading whitespace from every tool's description.

    FastMCP populates each registered tool's ``description`` from the
    function ``__doc__`` verbatim, which keeps the function-body indent
    on every line ("\\n    Get change history..."). Running each through
    ``inspect.cleandoc`` produces the readable, dedented form that
    directories like Smithery / Glama / PulseMCP score and render.
    """
    # FastMCP exposes its tool registry via ``_tool_manager._tools``.
    # If FastMCP changes that internal in a future release this no-ops
    # silently rather than blowing up the import.
    try:
        registry = mcp._tool_manager._tools
    except AttributeError:
        return
    for tool in registry.values():
        desc = getattr(tool, "description", None)
        if isinstance(desc, str) and desc:
            tool.description = inspect.cleandoc(desc)


_tighten_descriptions()


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main():
    # Configure structured logging once per process. Verbosity is controlled
    # by SELVEDGE_LOG_LEVEL (default WARNING).
    configure_logging()
    mcp.run()


if __name__ == "__main__":
    main()
