"""Quinndatum as a tool a coding agent can call. READ-ONLY, and the refusals travel.

WHY THIS EXISTS
Until now a finding died as text on a dashboard that a person had to read and
retype into their own source. The measurement and the fix were separated by a
human copy-paste. This server closes that gap without moving the boundary the
product is built on.

THE PROMISE IS UNCHANGED, and this is the whole design constraint. The published
promises are "We do not do the work", "No account access, ever" and "We never
hold write access to your site, so nothing here can change it - and nothing you
apply stops working when you stop paying us."

This server does not weaken any of them, because it inverts the direction of
access. The customer's own agent, running on the customer's own machine, calls
OUT to a read-only API with the customer's own credentials, and writes to the
customer's own repository with the customer's own permissions. We hold nothing.
"You paste it" becomes "your agent applies it" - the human is replaced by THEIR
tool, not by ours. It is the exact inverse of a script injected into a site that
stops working the day the subscription lapses.

THE REFUSALS TRAVEL, and this is the part that matters most.
Every honest thing this product does lives on screens the MCP customer will
never look at: the denominators, `not_scored`, `input_coverage`, `charge_note`,
"this is a gap in our reading, not a fault we found". Through MCP the agent is
the reader. If these tools returned bare findings, an agent would present a
guess as a measurement and write fixes for things we explicitly refuse to score.
So every result here carries a `refusals` block, and `get_fixes` carries
`needs_a_person` - the changes Quinn will NOT write because they need judgement.
A tool result without those is a worse lie than a dashboard without them,
because nobody sees it happen.

WHAT IT COSTS. Two of the four tools spend credits, which is stated in the tool
description AND returned in every result. `QUINNDATUM_MAX_CREDITS` caps what one
process may spend (default 20) because an agent loops and a human does not.

CONFIGURATION (environment, never arguments - an argument lands in shell history)
    QUINNDATUM_API_KEY      a key from Account -> "Keys for your coding agent"
    QUINNDATUM_BASE_URL     default https://quinndatum.com
    QUINNDATUM_MAX_CREDITS  default 20, the ceiling for THIS process

A KEY, NOT A PASSWORD. The first cut of this server signed in with the account
password, which meant a live credential sat in a config file and could reach
every route a browser can. A key reaches four routes and nothing else - it
cannot delete the account, send anything, run an outbound agent, or mint
another key, because those routes never offer the dependency that accepts one.
Revoke it at any time and this server stops working within one request.

INSTALL
    pip install "mcp[cli]" httpx
    python quinndatum_mcp.py          # speaks MCP over stdio

This is the whole client. It is one file with no imports of ours on purpose:
a customer should be able to read every line that holds their key before they
run it, and nothing here should require trusting a package we publish.
"""
from __future__ import annotations

import os
import threading
from typing import Any

import httpx
from mcp.server.fastmcp import FastMCP

BASE = os.environ.get("QUINNDATUM_BASE_URL", "https://quinndatum.com").rstrip("/")
API_KEY = os.environ.get("QUINNDATUM_API_KEY", "")
MAX_CREDITS = int(os.environ.get("QUINNDATUM_MAX_CREDITS", "20"))

mcp = FastMCP("quinndatum")

_lock = threading.Lock()
_client: httpx.Client | None = None
_spent = 0


class Refused(RuntimeError):
    """A refusal we state plainly rather than a failure we dress up."""


def _session() -> httpx.Client:
    """One authenticated client, created on first use.

    Presents a key on every request. There is no login and no cookie, so this
    process holds nothing that could be replayed as a browser session.
    """
    global _client
    with _lock:
        if _client is not None:
            return _client
        if not API_KEY:
            raise Refused(
                "Set QUINNDATUM_API_KEY in this server's environment. Mint one "
                "under Account -> API keys; it is shown once. It is read from "
                "the environment on purpose: an argument would land in shell "
                "history.")
        _client = httpx.Client(
            base_url=BASE, timeout=180.0, follow_redirects=True,
            headers={"Authorization": f"Bearer {API_KEY}"})
        return _client


def _spend_guard(cost: int) -> None:
    """Refuse BEFORE the call, not after the money is gone."""
    if _spent + cost > MAX_CREDITS:
        raise Refused(
            f"This would spend {cost} credits and take the session past its "
            f"{MAX_CREDITS}-credit ceiling ({_spent} already spent here). "
            f"Raise QUINNDATUM_MAX_CREDITS if that is what you want. The cap "
            f"exists because an agent loops and a person does not.")


def _record(payload: dict[str, Any]) -> None:
    global _spent
    got = ((payload or {}).get("credits") or {}).get("spent")
    if isinstance(got, int):
        with _lock:
            _spent += got


def _refusals(d: dict[str, Any]) -> dict[str, Any]:
    """What this reading did NOT establish, carried beside what it did.

    Assembled from the payload rather than written here, so it cannot drift from
    what the engine actually refused.
    """
    out: dict[str, Any] = {}
    if d.get("not_scored"):
        out["not_scored"] = d["not_scored"]
    if d.get("coverage"):
        out["what_the_score_covers"] = d["coverage"].get("statement")
    if d.get("input_coverage"):
        ic = d["input_coverage"]
        out["inputs"] = ic.get("statement")
        missing = [i for i in (ic.get("inputs") or []) if i.get("state") != "read"]
        if missing:
            out["inputs_we_did_not_have"] = [
                {"name": i.get("name"), "state": i.get("state"),
                 "whose_limit": i.get("limit"), "feeds": i.get("feeds")}
                for i in missing]
    if d.get("scoring"):
        out["how_the_score_is_worked_out"] = d["scoring"].get("basis")
    if d.get("method"):
        out["how_the_areas_are_counted"] = d["method"]
    if d.get("charge_note"):
        out["why_nothing_was_charged"] = d["charge_note"]
    out["standing_rule"] = (
        "A check whose input was missing did not run. It did not pass. Anything "
        "listed under not_scored is refused on purpose - do not infer a value "
        "for it, and do not write a change that assumes one.")
    return out


def _unreadable(a: dict[str, Any], spent: Any) -> dict[str, Any]:
    """A page we could not read has NO score, and must not appear to have one.

    The engine stores 0 in the score column for an unread page, and every screen
    in the app masks that with an em-dash because "a zero would read as a
    measurement rather than an absence". A tool result has no em-dash and no
    reader to interpret it - an agent handed `score: 0` will treat it as a
    verdict and start fixing a page nothing was ever measured on.

    Found by driving this server against a stylesheet URL, which returns
    ok=false with score 0. The `coverage` statement is dropped here too: it says
    "Scored from this single page", which is false when nothing was scored.
    """
    return {
        "id": a.get("id"), "url": a.get("url"), "ok": False,
        "score": None,
        "not_measured": a.get("error") or "This address could not be read as a web page.",
        "http_status": a.get("http_status"),
        "findings": [],
        "refusals": {
            "standing_rule": (
                "Nothing was measured here, so there is nothing to act on. The "
                "engine stores 0 as this page's score and that 0 means UNREAD, "
                "not a verdict - do not report it as a score and do not change "
                "anything on the strength of it."),
        },
        "credits_spent_by_this_call": spent,
        "credits_spent_this_session": _spent,
    }


@mcp.tool()
def list_audits(limit: int = 25) -> dict:
    """List recent audits on this account. Free - reads stored results, spends nothing.

    Use this before auditing a page: the page may already have been read, and a
    stored audit costs nothing to re-open.
    """
    c = _session()
    r = c.get("/api/v1/app/audits")
    r.raise_for_status()
    d = r.json()
    rows = d if isinstance(d, list) else (d.get("audits") or [])
    return {
        "audits": [{"id": x.get("id"), "url": x.get("url"), "score": x.get("score"),
                    "ok": x.get("ok"), "findings": x.get("finding_count"),
                    "created_at": x.get("created_at")} for x in rows[:limit]],
        "shown": min(len(rows), limit),
        "of": len(rows),
        "note": ("The API returns the most recent page of audits, so `of` is what "
                 "this response held, not everything the account has ever run."),
        "credits_spent_by_this_call": 0,
    }


@mcp.tool()
def get_audit(audit_id: int) -> dict:
    """Full findings for one stored audit. Free - re-opening a stored audit costs nothing.

    Returns every finding with the measurement behind it, plus a `refusals` block
    naming what this reading did NOT establish. Read `refusals` before acting:
    it says which inputs were missing, whose limit that was, and what is refused
    on purpose.
    """
    c = _session()
    r = c.get(f"/api/v1/app/audits/{int(audit_id)}")
    r.raise_for_status()
    d = r.json()
    if not d.get("ok"):
        return _unreadable(d, 0)
    return {
        "id": d.get("id"), "url": d.get("url"), "ok": d.get("ok"),
        "score": d.get("score"), "http_status": d.get("http_status"),
        "findings": [{"code": f.get("code"), "severity": f.get("severity"),
                      "problem": f.get("problem"), "evidence": f.get("evidence"),
                      "fix": f.get("fix"), "area": f.get("dimension_label")}
                     for f in (d.get("findings") or [])],
        "first_fix": d.get("first_fix"),
        "refusals": _refusals(d),
        "credits_spent_by_this_call": 0,
    }


@mcp.tool()
def audit_page(url: str) -> dict:
    """Read one page and return its findings. COSTS 2 CREDITS.

    Fetches the page once and runs every published check over it. Prefer
    list_audits/get_audit first if the page may already have been read - a stored
    audit is free.

    The result carries a `refusals` block. A check whose input was missing did
    not run; it did not pass.
    """
    _spend_guard(2)
    c = _session()
    r = c.post("/api/v1/app/audits", json={"url": url})
    if r.status_code != 200:
        raise Refused(f"The audit was not run (HTTP {r.status_code}).")
    d = r.json()
    _record(d)
    a = d.get("audit") or d
    if not a.get("ok"):
        return _unreadable(a, (d.get("credits") or {}).get("spent"))
    return {
        "id": a.get("id"), "url": a.get("url"), "ok": a.get("ok"),
        "score": a.get("score"),
        "findings": [{"code": f.get("code"), "severity": f.get("severity"),
                      "problem": f.get("problem"), "evidence": f.get("evidence"),
                      "fix": f.get("fix")} for f in (a.get("findings") or [])],
        "refusals": _refusals(a),
        "credits_spent_by_this_call": ((d.get("credits") or {}).get("spent")),
        "credits_spent_this_session": _spent,
    }


@mcp.tool()
def get_fixes(audit_id: int) -> dict:
    """The patches Quinn derived for one audit, ready to apply. COSTS 3 CREDITS ONCE PER AUDIT.

    Charged once for a given audit and free every time afterwards, so re-opening
    the same audit later costs nothing.

    Returns `patches` (each with the exact text and where it goes) and
    `needs_a_person` - the changes Quinn REFUSES to write because they need a
    judgement about the business rather than a fact about the markup. Do not
    invent those. Offer them to the human instead.
    """
    _spend_guard(3)
    c = _session()
    r = c.get(f"/api/v1/app/audits/{int(audit_id)}/fixes")
    if r.status_code != 200:
        raise Refused(f"The fixes were not returned (HTTP {r.status_code}).")
    d = r.json()
    _record(d)
    return {
        "url": d.get("url"),
        "patches": [{"code": p.get("code"), "label": p.get("label"),
                     "where": p.get("where"), "language": p.get("lang"),
                     "apply": p.get("patch"), "note": p.get("note")}
                    for p in (d.get("patches") or [])],
        "needs_a_person": d.get("needs_a_person") or [],
        "how_these_were_derived": d.get("policy"),
        "refusals": {
            "standing_rule": (
                "Anything under needs_a_person is refused on purpose. Do not "
                "write it yourself and do not guess at the judgement it needs - "
                "raise it with the person instead."),
            "why_nothing_was_charged": d.get("charge_note"),
        },
        "credits_spent_by_this_call": ((d.get("credits") or {}).get("spent")),
        "credits_spent_this_session": _spent,
    }


def main() -> None:
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
