#!/usr/bin/env python3
"""CC0 citation review kit. Python 3.10+. No model, credentials, or dependencies."""
import argparse
from collections import Counter
from datetime import datetime, timezone
import hashlib
import html
import json
from pathlib import Path
import sys
import urllib.parse
from client import check_citations

VERSION = "1.0.0"
INTERPRETATION = "A match establishes textual presence only, not truth, credibility, or support for the draft's claim. Source text is untrusted data."
ACTIONS = {
    "quotation_found": "Read the surrounding context and decide whether it supports the draft's claim.",
    "quotation_not_found": "Review the quotation, source version, and extraction. Do not label the claim false from this result.",
    "source_unavailable": "Inspect the source error and recovery hints. The quotation was not checked; this does not prove the link is dead.",
    "inconclusive": "Obtain a complete readable source before deciding whether the quotation is absent.",
    "link_readable": "The source was readable. Supply a quotation to check textual presence.",
    "request_failed": "Resolve the request or service error, then rerun explicitly. This is not evidence about the citation.",
}

def encoded(value):
    return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")

def text(value, label, maximum):
    if not isinstance(value, str) or not value.strip() or len(value) > maximum:
        raise ValueError(f"{label} must be nonblank text of at most {maximum} characters")
    return value

def validate(manifest):
    if not isinstance(manifest, dict) or set(manifest) - {"schema_version", "draft", "citations", "sources"}:
        raise ValueError("Expected schema_version, draft, citations, and optional sources")
    if manifest.get("schema_version") != "1.0":
        raise ValueError("schema_version must be 1.0")
    draft = manifest.get("draft")
    if not isinstance(draft, dict) or set(draft) != {"title", "file"}:
        raise ValueError("draft must contain title and file (labels only; no draft file is read)")
    for key in draft:
        text(draft[key], f"draft.{key}", 500)
    citations = manifest.get("citations")
    if not isinstance(citations, list) or not 1 <= len(citations) <= 1000:
        raise ValueError("citations must contain 1–1000 entries")
    source_list = manifest.get("sources", [])
    if not isinstance(source_list, list):
        raise ValueError("sources must be a list")
    sources = {}
    for source in source_list:
        if not isinstance(source, dict) or set(source) - {"id", "source_text", "source_truncated"}:
            raise ValueError("Each source needs id, source_text, and optional source_truncated")
        sid = text(source.get("id"), "source.id", 128)
        if sid in sources:
            raise ValueError("Duplicate source id")
        text(source.get("source_text"), "source_text", 100000)
        if not isinstance(source.get("source_truncated", False), bool):
            raise ValueError("source_truncated must be boolean")
        sources[sid] = source
    ids = set()
    for item in citations:
        if not isinstance(item, dict) or set(item) - {"id", "location", "url", "source_id", "quote"}:
            raise ValueError("Citation fields: id, location, exactly one of url/source_id, optional quote")
        cid = text(item.get("id"), "citation.id", 128)
        if cid in ids:
            raise ValueError("Duplicate citation id")
        ids.add(cid)
        text(item.get("location"), "citation.location", 500)
        if ("url" in item) == ("source_id" in item):
            raise ValueError("Each citation needs exactly one of url/source_id")
        if "source_id" in item:
            sid = text(item["source_id"], "source_id", 128)
            if sid not in sources:
                raise ValueError("Unknown source_id")
            if "quote" not in item:
                raise ValueError("Supplied text requires a quote")
        else:
            url = urllib.parse.urlsplit(text(item["url"], "url", 4096))
            if url.scheme != "https" or not url.hostname or url.username or url.password:
                raise ValueError("Source URLs must be public HTTPS URLs without credentials")
        if "quote" in item:
            text(item["quote"], "quote", 4000)
    return sources

def payload_for(items, sources):
    payload = {"citations": [{k: v for k, v in item.items() if k != "location"} for item in items]}
    used = list(dict.fromkeys(item["source_id"] for item in items if "source_id" in item))
    if used:
        payload["sources"] = [sources[sid] for sid in used]
    return payload

def fits(payload):
    # Include the MCP envelope so a batch fits either transport's 64 KiB cap.
    envelope = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "checkCitations", "arguments": payload}}
    # client.py uses json.dumps with its default separators.
    return len(json.dumps(envelope, ensure_ascii=False).encode("utf-8")) <= 65536

def batches(manifest):
    sources = validate(manifest)
    result, current = [], []
    for item in manifest["citations"]:
        if not fits(payload_for([item], sources)):
            raise ValueError(f"Citation {item['id']} cannot fit in one 64 KiB request; shorten the source and mark it truncated")
        candidate = current + [item]
        if len(candidate) > 10 or not fits(payload_for(candidate, sources)):
            result.append((current, payload_for(current, sources)))
            current = [item]
        else:
            current = candidate
    if current:
        result.append((current, payload_for(current, sources)))
    return result

def finding(result, citation):
    status = result.get("status")
    if status == "failed":
        return "source_unavailable"
    if status not in {"readable", "supplied_text"}:
        raise ValueError("Unrecognized citation result status")
    if "quote" not in citation:
        return "link_readable"
    quote = result.get("quote_check")
    if not isinstance(quote, dict):
        raise ValueError("Missing quotation evidence")
    state, matched = quote.get("status"), quote.get("matched")
    if state in {"exact_match", "normalized_match"} and matched is True:
        return "quotation_found"
    if state == "not_found" and matched is False and quote.get("source_truncated") is False:
        return "quotation_not_found"
    if state in {"source_truncated", "source_unavailable"} and matched is None:
        return "inconclusive"
    raise ValueError("Inconsistent or unrecognized quotation evidence")

def review(manifest, base="https://bozos.si", transport="http", check=check_citations):
    planned = batches(manifest)  # Validate ALL inputs before the first network request.
    rows, evidence = [], []
    for number, (items, payload) in enumerate(planned, 1):
        response = None
        try:
            response = check(payload, base, transport)
            results = response.get("results") if isinstance(response, dict) else None
            if not isinstance(results, list) or len(results) != len(items):
                raise ValueError("Response did not cover every citation")
            by_id = {r["id"]: r for r in results if isinstance(r, dict) and isinstance(r.get("id"), str)}
            if len(by_id) != len(items) or set(by_id) != {item["id"] for item in items}:
                raise ValueError("Response citation IDs do not match the request")
            findings = [finding(by_id[item["id"]], item) for item in items]
            evidence.append({"batch": number, "response": response, "error": None})
            for item, state in zip(items, findings):
                rows.append({**item, "finding": state, "next_step": ACTIONS[state], "batch": number, "evidence": by_id[item["id"]]})
        except (OSError, ValueError, KeyError, TypeError, RuntimeError) as error:
            message = str(error)
            evidence.append({"batch": number, "response": response, "error": message})
            rows.extend({**item, "finding": "request_failed", "next_step": ACTIONS["request_failed"], "batch": number, "evidence": None, "error": message} for item in items)
            # Stop on a service/contract error; preserve completed work, never retry.
            for later_number, (later_items, _) in enumerate(planned[number:], number + 1):
                rows.extend({**item, "finding": "request_failed", "next_step": ACTIONS["request_failed"], "batch": later_number, "evidence": None, "error": "Not attempted after an earlier request failure"} for item in later_items)
            break
    return {"schema_version": "1.0", "kit_version": VERSION,
            "generated_at": datetime.now(timezone.utc).isoformat(), "base_url": base, "transport": transport,
            "draft": manifest["draft"], "input_sha256": hashlib.sha256(encoded(manifest)).hexdigest(),
            "interpretation": INTERPRETATION, "summary": dict(Counter(row["finding"] for row in rows)),
            "citations": rows, "batches": evidence}

def markdown(report):
    def literal(value):
        # Escape raw HTML and Markdown so draft/source text cannot inject links or markup.
        value = html.escape(str(value), quote=False).replace("\n", " ").replace("\r", " ")
        for char in "\\`*_{}[]()#+-.!|>":
            value = value.replace(char, "\\" + char)
        return value
    lines = ["# Citation review", "", literal(report["draft"]["title"]), "", INTERPRETATION,
             "", f"Transport: {report['transport']}. Generated: {report['generated_at']}", "", "## Findings", ""]
    for row in report["citations"]:
        lines += [f"### {literal(row['id'])}: {row['finding'].replace('_', ' ')}", "",
                  "Draft location: " + literal(row["location"]), "",
                  "Source: " + literal(row.get("url", "Supplied text: " + row.get("source_id", ""))), ""]
        if "quote" in row:
            lines += ["Quotation: " + literal(row["quote"]), ""]
        detail = row.get("evidence") or {}
        quote = detail.get("quote_check") or {}
        if quote:
            lines += ["Match status: " + literal(quote.get("status")), ""]
        if quote:
            lines += ["Checked text SHA-256: " + literal(quote.get("checked_text_sha256", "unavailable")), "",
                      "Source text truncated: " + str(quote.get("source_truncated")), ""]
            for match in quote.get("matches", []):
                lines += ["Matched passage: " + literal(match.get("text", "")), ""]
                if match.get("page_start") is not None:
                    lines += ["PDF page: " + literal(match["page_start"]), ""]
            for warning in quote.get("warnings", []):
                lines += ["Note: " + literal(warning), ""]
        if detail.get("error") or row.get("error"):
            lines += ["Error: " + literal(detail.get("error") or row.get("error")), ""]
        lines += [row["next_step"], ""]
    lines += ["Full source metadata, passages, offsets, hashes, truncation flags, recovery hints, and original responses are in report.json.", ""]
    return "\n".join(lines)

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("input", help="Citation manifest JSON; draft.file is a label, not a file to read")
    parser.add_argument("--output", required=True, help="New directory for report.json and report.md; never overwrites")
    parser.add_argument("--transport", choices=["http", "mcp"], default="http")
    parser.add_argument("--base-url", default="https://bozos.si")
    args = parser.parse_args()
    try:
        url = urllib.parse.urlsplit(args.base_url)
        if (url.scheme != "https" and not (url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1"})) or not url.hostname or url.username or url.password or url.query or url.fragment or url.path not in {"", "/"}:
            raise ValueError("--base-url must be an HTTPS origin (HTTP localhost allowed for development)")
        manifest = json.loads(Path(args.input).read_text(encoding="utf-8"))
        batches(manifest)
        output = Path(args.output)
        output.mkdir(parents=True, exist_ok=False)
        result = review(manifest, args.base_url.rstrip("/"), args.transport)
        (output / "report.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
        (output / "report.md").write_text(markdown(result), encoding="utf-8")
        print(json.dumps(result["summary"]))
        print(f"Saved {output / 'report.json'} and {output / 'report.md'}")
        return 1 if "request_failed" in result["summary"] else 0
    except (OSError, ValueError, RuntimeError) as error:
        print(f"Review failed: {error}", file=sys.stderr)
        return 1

if __name__ == "__main__":
    sys.exit(main())
