#!/usr/bin/env python3
"""citation_tracker.py — Hypothesis-testing three-count audit + tier tagging.

Stdlib-only. Extended for dossier's hypothesis-testing discipline:

  - searches (sent)
  - sources received (raw count across all queries)
  - sources cited (made it into DOCX)
  - Per query: supporting / disconfirming / inconclusive classification
  - Per cited source: primary / secondary / tertiary tier

Enables the ≥30% disconfirming rule via `disconfirming_evidence_balance.py`.
Enables verdict determination via tier-weighted balance.

Sessions persist at ~/.dossier_sessions/<session>.json.

Usage:
    python citation_tracker.py --action start --session dossier-MS-20260515 --subject "Microsoft" --hypothesis "consolidating AI on Foundry"
    python citation_tracker.py --action record_search --session ... --query "..." --classification supporting
    python citation_tracker.py --action record_search --session ... --query "..." --classification disconfirming
    python citation_tracker.py --action record_received --session ... --count 12
    python citation_tracker.py --action record_cited --session ... --url "https://..." --tier primary --classification supporting
    python citation_tracker.py --action status --session ...
    python citation_tracker.py --action close --session ...
"""

import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

# Windows consoles often default to a legacy codepage (e.g. cp1252) that cannot
# encode the Unicode glyphs this script prints (issue #969). Re-encode
# stdout/stderr as UTF-8 with replacement so output never crashes at print time.
if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")


SESSIONS_DIR = Path.home() / ".dossier_sessions"
VALID_CLASSIFICATIONS = ["supporting", "disconfirming", "inconclusive"]
VALID_TIERS = ["primary", "secondary", "tertiary"]


def session_path(name: str) -> Path:
    return SESSIONS_DIR / f"{name}.json"


def load_session(name: str) -> Dict[str, Any]:
    p = session_path(name)
    if not p.exists():
        raise FileNotFoundError(f"Session not found: {name}")
    return json.loads(p.read_text(encoding="utf-8"))


def save_session(name: str, data: Dict[str, Any]) -> None:
    SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
    session_path(name).write_text(json.dumps(data, indent=2), encoding="utf-8")


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def action_start(name: str, subject: Optional[str], hypothesis: Optional[str], purpose: Optional[str]) -> Dict[str, Any]:
    if session_path(name).exists():
        raise FileExistsError(f"Session already exists: {name}")
    data: Dict[str, Any] = {
        "session": name,
        "subject": subject or "",
        "hypothesis": hypothesis or "",
        "hypothesis_is_implicit_fallback": False,
        "purpose": purpose or "",
        "started_at": now_iso(),
        "ended_at": None,
        "searches": [],
        "received_log": [],
        "cited": [],
        "counts": {
            "searches": 0,
            "supporting_searches": 0,
            "disconfirming_searches": 0,
            "inconclusive_searches": 0,
            "received_total": 0,
            "cited_total": 0,
            "cited_primary": 0,
            "cited_secondary": 0,
            "cited_tertiary": 0,
            "cited_supporting": 0,
            "cited_disconfirming": 0,
            "cited_inconclusive": 0,
        },
        "byok_mcps_used": [],
    }
    save_session(name, data)
    return data


def action_record_search(name: str, query: str, classification: str) -> Dict[str, Any]:
    data = load_session(name)
    if classification not in VALID_CLASSIFICATIONS:
        raise ValueError(f"Invalid classification '{classification}'. Pick from: {VALID_CLASSIFICATIONS}")
    data["searches"].append({"query": query, "classification": classification, "at": now_iso()})
    data["counts"]["searches"] += 1
    data["counts"][f"{classification}_searches"] += 1
    save_session(name, data)
    return data


def action_record_received(name: str, count: int) -> Dict[str, Any]:
    data = load_session(name)
    data["received_log"].append({"count": count, "at": now_iso()})
    data["counts"]["received_total"] += count
    save_session(name, data)
    return data


def action_record_cited(name: str, url: str, tier: str, classification: str, title: Optional[str]) -> Dict[str, Any]:
    data = load_session(name)
    if tier not in VALID_TIERS:
        raise ValueError(f"Invalid tier '{tier}'. Pick from: {VALID_TIERS}")
    if classification not in VALID_CLASSIFICATIONS:
        raise ValueError(f"Invalid classification '{classification}'. Pick from: {VALID_CLASSIFICATIONS}")
    if any(c["url"] == url for c in data["cited"]):
        return data
    data["cited"].append({"url": url, "tier": tier, "classification": classification, "title": title, "at": now_iso()})
    data["counts"]["cited_total"] += 1
    data["counts"][f"cited_{tier}"] += 1
    data["counts"][f"cited_{classification}"] += 1
    save_session(name, data)
    return data


def action_mark_implicit_fallback(name: str) -> Dict[str, Any]:
    data = load_session(name)
    data["hypothesis_is_implicit_fallback"] = True
    save_session(name, data)
    return data


def action_record_byok(name: str, mcp_name: str) -> Dict[str, Any]:
    data = load_session(name)
    if mcp_name not in data["byok_mcps_used"]:
        data["byok_mcps_used"].append(mcp_name)
    save_session(name, data)
    return data


def action_status(name: str) -> Dict[str, Any]:
    return load_session(name)


def action_close(name: str) -> Dict[str, Any]:
    data = load_session(name)
    if data.get("ended_at") is None:
        data["ended_at"] = now_iso()
        save_session(name, data)
    return data


def compute_verdict(data: Dict[str, Any]) -> str:
    """Tier-weighted verdict from cited evidence."""
    c = data["counts"]
    # Tier weights: primary=3, secondary=2, tertiary=1
    # But we only have per-tier totals + per-classification totals (not crossed)
    # Approximate: assume tier distribution is uniform across classifications
    # For exact: would need full per-citation iteration
    support = c["cited_supporting"]
    disconfirm = c["cited_disconfirming"]
    total = support + disconfirm
    if total < 3:
        return "INCONCLUSIVE"
    if support >= 2 * disconfirm:
        return "SUPPORTED"
    if disconfirm > support:
        return "DISPROVEN"
    return "PARTIALLY SUPPORTED"


def disconfirming_ratio(data: Dict[str, Any]) -> float:
    c = data["counts"]
    if c["searches"] == 0:
        return 0.0
    return c["disconfirming_searches"] / c["searches"]


def render_status_human(data: Dict[str, Any]) -> str:
    out: List[str] = []
    out.append(f"Session:           {data['session']}")
    out.append(f"Subject:           {data.get('subject', '(unset)')}")
    out.append(f"Hypothesis:        {data.get('hypothesis', '(unset)')}")
    if data.get("hypothesis_is_implicit_fallback"):
        out.append(f"                   [IMPLICIT FALLBACK — user did not state explicit hypothesis]")
    out.append(f"Purpose:           {data.get('purpose', '(unset)')}")
    out.append(f"BYOK MCPs used:    {', '.join(data.get('byok_mcps_used', [])) or '(none)'}")
    out.append("")
    c = data["counts"]
    out.append("Search counts:")
    out.append(f"  Total searches:        {c['searches']}")
    out.append(f"  Supporting:            {c['supporting_searches']}")
    out.append(f"  Disconfirming:         {c['disconfirming_searches']}")
    out.append(f"  Inconclusive:          {c['inconclusive_searches']}")
    ratio = disconfirming_ratio(data) * 100
    rule_status = "✓ meets ≥30% rule" if ratio >= 30 else "✗ BELOW 30% — confirmation bias risk"
    out.append(f"  Disconfirming ratio:   {ratio:.0f}%  {rule_status}")
    out.append("")
    out.append("Citation counts:")
    out.append(f"  Total received:        {c['received_total']}")
    out.append(f"  Total cited:           {c['cited_total']}")
    out.append(f"  By tier — primary:     {c['cited_primary']}")
    out.append(f"           secondary:   {c['cited_secondary']}")
    out.append(f"           tertiary:    {c['cited_tertiary']}")
    out.append(f"  By classification — supporting:    {c['cited_supporting']}")
    out.append(f"                      disconfirming: {c['cited_disconfirming']}")
    out.append(f"                      inconclusive:  {c['cited_inconclusive']}")
    out.append("")
    out.append(f"Verdict (tier-weighted):  **{compute_verdict(data)}**")
    out.append("")
    out.append("Audit block for DOCX Section 9:")
    out.append(
        f"  Queries sent: {c['searches']} ({c['supporting_searches']} supporting / {c['disconfirming_searches']} disconfirming / {c['inconclusive_searches']} inconclusive). "
        f"Sources received: {c['received_total']}. Sources cited: {c['cited_total']} "
        f"({c['cited_primary']} primary / {c['cited_secondary']} secondary / {c['cited_tertiary']} tertiary). "
        f"Disconfirming ratio: {ratio:.0f}%. Verdict: {compute_verdict(data)}."
    )
    return "\n".join(out)


def main(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument(
        "--action",
        required=True,
        choices=[
            "start", "record_search", "record_received", "record_cited",
            "mark_implicit_fallback", "record_byok",
            "status", "list", "close",
        ],
    )
    parser.add_argument("--session")
    parser.add_argument("--subject")
    parser.add_argument("--hypothesis")
    parser.add_argument("--purpose")
    parser.add_argument("--query")
    parser.add_argument("--classification", choices=VALID_CLASSIFICATIONS)
    parser.add_argument("--count", type=int)
    parser.add_argument("--url")
    parser.add_argument("--tier", choices=VALID_TIERS)
    parser.add_argument("--title")
    parser.add_argument("--mcp", help="(record_byok only) MCP name")
    parser.add_argument("--output", choices=["human", "json"], default="human")
    args = parser.parse_args(argv)

    try:
        if args.action == "start":
            result = action_start(args.session, args.subject, args.hypothesis, args.purpose)
        elif args.action == "record_search":
            result = action_record_search(args.session, args.query, args.classification)
        elif args.action == "record_received":
            result = action_record_received(args.session, args.count)
        elif args.action == "record_cited":
            result = action_record_cited(args.session, args.url, args.tier, args.classification, args.title)
        elif args.action == "mark_implicit_fallback":
            result = action_mark_implicit_fallback(args.session)
        elif args.action == "record_byok":
            result = action_record_byok(args.session, args.mcp)
        elif args.action == "status":
            result = action_status(args.session)
        elif args.action == "close":
            result = action_close(args.session)
        else:
            SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
            result = [
                {"session": p.stem, **{k: v for k, v in json.loads(p.read_text(encoding="utf-8")).items() if k in ("subject", "started_at", "ended_at", "counts")}}
                for p in sorted(SESSIONS_DIR.glob("*.json"))
            ]
    except (FileNotFoundError, FileExistsError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2

    if args.output == "json":
        print(json.dumps(result, indent=2, default=str))
    else:
        if args.action == "list":
            print(json.dumps(result, indent=2, default=str))
        else:
            print(render_status_human(result))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
