#!/usr/bin/env python3
"""
verify.py — provenance verifier for video claims.
Part of "Looking Glass: an evidence dossier".

Four checks, all of which either work or say plainly that they did not. This
script never fabricates a result and never emits a confidence score it cannot
compute.

  1. archive      Internet Archive CDX capture history for any URL.
                  The strongest durable signal available on the open web:
                  proves a URL existed no later than a given date.
  2. playlist     YouTube playlist metadata via yt-dlp --flat-playlist.
                  Owner, modified date, per-video uploader/title/duration/views.
                  (Single-video --dump-json is frequently blocked by YouTube bot
                  checks from datacenter IPs; failures are reported, not guessed.)
  3. describe     Signal analysis of a pasted video description: self-disclosed
                  AI/own narration, stock-footage vendors, affiliate funnels,
                  hashtag stuffing, suppression framing. Works fully offline.
  4. transcript   Fuzzy match of a transcript or caption snippet against a
                  bundled corpus of verbatim lines from the January 2012
                  Project Camelot interview, using difflib.

Usage
-----
  python3 verify.py archive https://www.youtube.com/watch?v=VtHCofbE1PM
  python3 verify.py playlist "https://www.youtube.com/playlist?list=PLq9uLOwjoHHrPGh_P4815IJFCUbXdeOsv"
  python3 verify.py describe -f description.txt        # or: ... describe -   (stdin)
  python3 verify.py transcript "get to a target usually by some extraordinary means"
  python3 verify.py all https://www.youtube.com/watch?v=VtHCofbE1PM

Add --json for machine-readable output.

Requires: Python 3.9+ (standard library only). `yt-dlp` on PATH for the
playlist check only.
"""
from __future__ import annotations

import argparse
import difflib
import json
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request

UA = "Mozilla/5.0 (compatible; looking-glass-verify/1.0; provenance checking)"
TIMEOUT = 40

# --------------------------------------------------------------------------- #
# 1. Internet Archive capture history
# --------------------------------------------------------------------------- #

def archive_captures(url: str, limit: int = 500) -> dict:
    """Query the Internet Archive CDX API for capture history of `url`."""
    api = (
        "http://web.archive.org/cdx/search/cdx?url="
        + urllib.parse.quote(url, safe="")
        + f"&output=json&limit={int(limit)}&fl=timestamp,original,statuscode,digest&collapse=digest"
    )
    out = {"check": "archive", "url": url, "api": api, "ok": False}
    try:
        req = urllib.request.Request(api, headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            rows = json.loads(r.read().decode("utf-8", "replace") or "[]")
    except Exception as exc:  # network blocked, rate limited, DNS, etc.
        out["error"] = f"{type(exc).__name__}: {exc}"
        out["note"] = (
            "The CDX API could not be reached from this host. This is a failure of "
            "the check, not a finding about the URL."
        )
        fb = _availability_fallback(url)
        if fb:
            out["fallback"] = fb
            out["ok"] = True
            out["method"] = "wayback-availability-api (degraded fallback)"
            out["earliest"] = fb.get("earliest")
            out["earliest_raw"] = fb.get("earliest_raw")
            out["latest"] = fb.get("latest")
            out["latest_raw"] = fb.get("latest_raw")
            out["earliest_snapshot"] = fb.get("earliest_snapshot")
            out["count"] = None
            out["note"] = (
                "CDX was unreachable from this host, so this result comes from the "
                "Wayback availability API instead: it returns the capture closest to "
                "a requested date, so 'earliest' means 'closest capture to 1996' and "
                "the total capture count is NOT available. Run this script locally for "
                "the full CDX capture list."
            )
        return out

    out["method"] = "cdx"

    if not rows or len(rows) < 2:
        out["ok"] = True
        out["count"] = 0
        out["note"] = (
            "No captures found. Note the asymmetry: absence of a capture is NOT "
            "absence of publication — the Archive's crawl coverage is uneven. "
            "This check can only ever prove 'existed no later than', never "
            "'did not exist before'."
        )
        return out

    hdr, data = rows[0], rows[1:]
    stamps = sorted(row[hdr.index("timestamp")] for row in data)

    def fmt(ts: str) -> str:
        return f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]} {ts[8:10]}:{ts[10:12]}:{ts[12:14]} UTC"

    out.update(
        ok=True,
        count=len(stamps),
        earliest=fmt(stamps[0]),
        earliest_raw=stamps[0],
        latest=fmt(stamps[-1]),
        latest_raw=stamps[-1],
        earliest_snapshot=f"https://web.archive.org/web/{stamps[0]}/{url}",
        years=sorted({s[:4] for s in stamps}),
        note=(
            "Proves the URL existed no later than the earliest capture. "
            "Does not prove it did not exist earlier."
        ),
    )
    return out


def _availability_fallback(url: str) -> dict | None:
    """Degraded archive check: the Wayback availability API returns the capture
    closest to a requested timestamp. It gives no capture count, so the result is
    labelled as degraded wherever it is used."""
    def closest(ts: str):
        api = (
            "https://archive.org/wayback/available?url="
            + urllib.parse.quote(url, safe="")
            + "&timestamp="
            + ts
        )
        try:
            req = urllib.request.Request(api, headers={"User-Agent": UA})
            with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
                data = json.loads(r.read().decode("utf-8", "replace"))
            snap = (data.get("archived_snapshots") or {}).get("closest")
            return snap if snap and snap.get("available") else None
        except Exception:
            return None

    first, last = closest("19960101"), closest("29991231")
    if not first and not last:
        return None

    def fmt(ts: str) -> str:
        return f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]} {ts[8:10]}:{ts[10:12]}:{ts[12:14]} UTC"

    res = {"api": "https://archive.org/wayback/available"}
    if first:
        res["earliest_raw"] = first["timestamp"]
        res["earliest"] = fmt(first["timestamp"])
        res["earliest_snapshot"] = first["url"]
    if last:
        res["latest_raw"] = last["timestamp"]
        res["latest"] = fmt(last["timestamp"])
    return res


# --------------------------------------------------------------------------- #
# 2. Playlist metadata via yt-dlp
# --------------------------------------------------------------------------- #

def playlist_info(url: str) -> dict:
    out = {"check": "playlist", "url": url, "ok": False}
    cmd = ["yt-dlp", "--flat-playlist", "--dump-single-json", "--no-warnings", url]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
    except FileNotFoundError:
        out["error"] = "yt-dlp is not installed or not on PATH."
        return out
    except subprocess.TimeoutExpired:
        out["error"] = "yt-dlp timed out after 180 s."
        return out

    if proc.returncode != 0 or not proc.stdout.strip():
        tail = (proc.stderr or "").strip().splitlines()[-4:]
        out["error"] = "yt-dlp failed: " + " / ".join(tail) if tail else "yt-dlp failed."
        out["note"] = (
            "Playlist extraction usually works. Single-video metadata calls are "
            "frequently blocked by YouTube bot checks from datacenter IP ranges. "
            "This is reported as a failed check rather than filled with a guess."
        )
        return out

    try:
        data = json.loads(proc.stdout)
    except json.JSONDecodeError as exc:
        out["error"] = f"could not parse yt-dlp output: {exc}"
        return out

    entries = data.get("entries") or []
    videos = []
    for e in entries:
        if not isinstance(e, dict):
            continue
        videos.append(
            {
                "id": e.get("id"),
                "title": e.get("title"),
                "uploader": e.get("uploader") or e.get("channel"),
                "duration": _hms(e.get("duration")),
                "views": e.get("view_count"),
                "available": bool(e.get("title")) and e.get("title") not in ("[Deleted video]", "[Private video]"),
            }
        )
    out.update(
        ok=True,
        title=data.get("title"),
        playlist_id=data.get("id"),
        owner=data.get("uploader") or data.get("channel"),
        owner_id=data.get("channel_id") or data.get("uploader_id"),
        modified=data.get("modified_date"),
        view_count=data.get("view_count"),
        video_count=len(videos),
        videos=videos,
        note=(
            "'modified' is the playlist's last-modified date, not an upload date. "
            "Uploader names differing from the playlist owner mean the owner "
            "curated other people's uploads."
        ),
    )
    return out


def _hms(secs) -> str | None:
    try:
        s = int(secs)
    except (TypeError, ValueError):
        return None
    h, rem = divmod(s, 3600)
    m, sec = divmod(rem, 60)
    return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"


# --------------------------------------------------------------------------- #
# 3. Description signal analysis  (always works offline)
# --------------------------------------------------------------------------- #

SIGNALS: list[tuple[str, str, list[str], str]] = [
    (
        "self_disclosed_production",
        "Self-disclosed AI or own narration/script",
        [
            r"narration\s*[-–—:]", r"script\s*[-–—:]", r"voice ?over\s*[-–—:]",
            r"\bai voice\b", r"\btext[- ]to[- ]speech\b", r"\btts\b",
            r"\bai[- ]generated\b", r"\bai narration\b", r"\bsynthetic voice\b",
            r"\bai upscal\w*", r"\bgenerated with ai\b", r"\bmade with ai\b",
        ],
        "The channel states its own production method. This is disclosure, not accusation — "
        "and it distinguishes machine-made packaging from the underlying footage.",
    ),
    (
        "stock_footage",
        "Licensed stock-footage / stock-audio vendors",
        [
            r"videoblocks", r"artgrid", r"envato", r"storyblocks", r"epidemic ?sound",
            r"audiojungle", r"artlist", r"pond5", r"shutterstock", r"getty ?images",
            r"\bfair use\b",
        ],
        "Indicates an assembled edit over licensed B-roll rather than original reporting or footage.",
    ),
    (
        "monetisation_funnel",
        "Affiliate / course / monetisation funnel",
        [
            r"affiliate", r"commission", r"masterclass", r"mastermind", r"\bcourse\b",
            r"patreon", r"buymeacoffee", r"ko-?fi", r"\bbonuses? worth\b",
            r"\d+\s*%\s*discount", r"free seat", r"limited time", r"link\.",
            r"promo ?code", r"\bmerch\b", r"paypal\.me", r"\bdonate\b",
        ],
        "A revenue mechanism attached to the claim. Monetisation is structural tell #6: "
        "it creates an incentive to escalate rather than resolve.",
    ),
    (
        "suppression_framing",
        "Suppression / forbidden-knowledge framing",
        [
            r"they tried (?:desperately )?to stop", r"\bbanned\b", r"\bdeleted\b",
            r"\bcensored\b", r"they don'?t want you", r"before (?:it|this) (?:gets|is) (?:taken|removed)",
            r"\bsuppressed\b", r"\bleaked\b", r"forbidden", r"the truth about",
            r"shook my sense of reality", r"\bwake up\b",
        ],
        "Frames free availability as suppression. Structural tell #5: official silence or an "
        "absent record is presented as proof rather than as absence of evidence.",
    ),
    (
        "seo_stuffing",
        "SEO / hashtag stuffing and serialisation",
        [r"subscribe for part", r"part\s*(?:i{1,3}|[1-9])\b", r"like and subscribe", r"turn on notifications"],
        "Optimisation for watch time and discovery, which is a revenue signal rather than an editorial one.",
    ),
]


def analyze_description(text: str) -> dict:
    text = text or ""
    low = text.lower()
    findings = []
    for key, label, patterns, meaning in SIGNALS:
        hits = []
        for pat in patterns:
            for m in re.finditer(pat, low, re.I):
                start, end = max(0, m.start() - 45), min(len(text), m.end() + 45)
                hits.append(
                    {
                        "pattern": pat,
                        "matched": text[m.start() : m.end()],
                        "context": ("…" if start else "") + text[start:end].replace("\n", " ").strip() + ("…" if end < len(text) else ""),
                    }
                )
        if hits:
            findings.append({"key": key, "label": label, "meaning": meaning, "count": len(hits), "hits": hits[:8]})

    tags = re.findall(r"#\w+", text)
    urls = re.findall(r"https?://[^\s)>\]]+", text)
    return {
        "check": "describe",
        "ok": True,
        "chars": len(text),
        "hashtag_count": len(tags),
        "hashtags": tags[:40],
        "url_count": len(urls),
        "urls": urls[:25],
        "signal_count": sum(f["count"] for f in findings),
        "categories_hit": [f["key"] for f in findings],
        "findings": findings,
        "note": (
            "These are signals of a commercial content workflow, quoted from the "
            "description's own words. They are not proof of intent, and they say "
            "nothing about whether the underlying footage is authentic. "
            "Hashtag counts above ~10 typically indicate discovery optimisation."
        ),
    }


# --------------------------------------------------------------------------- #
# 4. Transcript matching against the bundled 2012 corpus
# --------------------------------------------------------------------------- #

EMBEDDED_CORPUS = {
    "source": "Project Camelot interview with \"Bill Wood\" (William Newel Brockbrader), January 2012, 2:30:48",
    "mirrors": [
        "https://archive.org/details/bill-wood-interview-with-kerry-cassidy-project-camelot",
        "https://www.youtube.com/watch?v=VtHCofbE1PM",
    ],
    "note": "Timestamps refer to the 2:30:48 original. Re-cuts renumber time.",
    "lines": [
        {
            "t": 61,
            "speaker": "Kerry Cassidy",
            "text": "...And he is going to first of all speak on the subject of a disclaimer in regard to a project that he is using this for on a personal level.",
            "significance": "Introduces the disclaimer passage removed from the 2025-12-01 \"Be Inspired\" recut.",
        },
        {
            "t": 68,
            "speaker": "\"Bill Wood\"",
            "text": "Hi. Um, I just want to disclose to everybody that I am writing a fictional book about uh this interview and the things that I discuss in this interview, and uh the reason for this interview is uh for purposes of marketing that book — and that book, of course, is fictional.",
            "significance": "The decisive finding: the source declares the content fiction, produced to market a novel, inside the first 90 seconds.",
        },
        {
            "t": 90,
            "speaker": "Kerry Cassidy",
            "text": "Okay, great. And so at this point we are going to start in the beginning...",
            "significance": "Closes the disclaimer passage; the cut point in recuts that omit it.",
        },
        {
            "t": 856,
            "speaker": "\"Bill Wood\"",
            "text": "get to a target usually by some extraordinary means of jumping out of an airplane or walking further than most people would imagine",
            "significance": "The line most often captioned in short clips circulating in 2025-26; anchors a clip to the 2012 original at 14:16.",
        },
    ],
}


def load_corpus(path: str | None = None) -> dict:
    candidates = [path] if path else []
    here = os.path.dirname(os.path.abspath(__file__))
    candidates += [
        os.path.join(here, "corpus_2012.json"),
        os.path.join(here, "backend", "corpus_2012.json"),
        os.path.join(here, "assets", "corpus_2012.json"),
    ]
    for c in candidates:
        if c and os.path.exists(c):
            try:
                with open(c, encoding="utf-8") as fh:
                    return json.load(fh)
            except Exception:
                pass
    return EMBEDDED_CORPUS


def _norm(s: str) -> str:
    s = s.lower().replace("’", "'").replace("—", " ").replace("–", " ")
    s = re.sub(r"\[[^\]]*\]", " ", s)          # [Music], [Applause]
    s = re.sub(r"\b(uh|um|er)\b", " ", s)      # filler
    s = re.sub(r"[^a-z0-9' ]", " ", s)
    return re.sub(r"\s+", " ", s).strip()


def transcript_match(snippet: str, corpus: dict | None = None) -> dict:
    corpus = corpus or load_corpus()
    q = _norm(snippet or "")
    if len(q) < 12:
        return {
            "check": "transcript",
            "ok": False,
            "error": "Snippet too short to match meaningfully (need ~12+ characters of text).",
        }

    scored = []
    for line in corpus["lines"]:
        ref = _norm(line["text"])
        whole = difflib.SequenceMatcher(None, q, ref).ratio()
        # Best matching window, so a short snippet can match a long line.
        sm = difflib.SequenceMatcher(None, ref, q)
        m = sm.find_longest_match(0, len(ref), 0, len(q))
        contained = m.size / max(1, len(q))
        score = max(whole, contained)
        scored.append(
            {
                "t": line["t"],
                "timestamp": f"{line['t'] // 60}:{line['t'] % 60:02d}",
                "speaker": line["speaker"],
                "text": line["text"],
                "significance": line["significance"],
                "similarity": round(score, 4),
                "longest_common_run_chars": m.size,
            }
        )
    scored.sort(key=lambda d: -d["similarity"])
    best = scored[0]
    if best["similarity"] >= 0.85:
        verdict = "verbatim or near-verbatim match to the 2012 original"
    elif best["similarity"] >= 0.6:
        verdict = "partial match — likely the same passage, with transcription differences"
    elif best["similarity"] >= 0.35:
        verdict = "weak match — treat as inconclusive"
    else:
        verdict = "no match in the bundled corpus"
    return {
        "check": "transcript",
        "ok": True,
        "corpus_source": corpus["source"],
        "corpus_size": len(corpus["lines"]),
        "verdict": verdict,
        "best": best,
        "all": scored,
        "note": (
            "The bundled corpus is small and deliberately limited to lines confirmed "
            "verbatim. A match anchors a clip to the 2012 original; a non-match means "
            "only that the snippet is not one of these lines, not that it is inauthentic."
        ),
    }


# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #

def _read_text(arg: str | None, file_arg: str | None) -> str:
    if file_arg == "-" or arg == "-":
        return sys.stdin.read()
    if file_arg:
        with open(file_arg, encoding="utf-8") as fh:
            return fh.read()
    return arg or ""


def _print_human(res: dict) -> None:
    check = res.get("check")
    bar = "=" * 68
    print(bar)
    print(f"CHECK: {check}")
    print(bar)
    if not res.get("ok"):
        print("RESULT: check did not complete.")
        print("REASON:", res.get("error", "unknown"))
        if res.get("note"):
            print("NOTE:  ", res["note"])
        return
    if check == "archive":
        if res.get("method"):
            print(f"method          : {res['method']}")
        if res.get("count") or res.get("earliest"):
            print(f"captures        : {res.get('count') if res.get('count') is not None else 'unavailable (degraded fallback)'}")
            print(f"earliest        : {res['earliest']}")
            print(f"latest          : {res['latest']}")
            if res.get("years"):
                print(f"years captured  : {', '.join(res['years'])}")
            print(f"earliest snapshot: {res.get('earliest_snapshot')}")
        else:
            print("captures        : 0")
    elif check == "playlist":
        print(f"title    : {res.get('title')}")
        print(f"owner    : {res.get('owner')}  ({res.get('owner_id')})")
        print(f"modified : {res.get('modified')}")
        print(f"views    : {res.get('view_count')}")
        print(f"videos   : {res.get('video_count')}")
        for v in res.get("videos", []):
            print(f"  - [{v['id']}] {v['duration'] or '?':>8}  {str(v['views'] or '?'):>9} views  "
                  f"{(v['uploader'] or '?')[:28]:<28} {v['title']}")
    elif check == "describe":
        print(f"length {res['chars']} chars · {res['hashtag_count']} hashtags · {res['url_count']} URLs · "
              f"{res['signal_count']} signal hits in {len(res['findings'])} categories")
        for f in res["findings"]:
            print(f"\n[{f['label']}]  ({f['count']} hits)")
            print(f"  why it matters: {f['meaning']}")
            for h in f["hits"]:
                print(f"    · matched {h['matched']!r} in: {h['context']}")
    elif check == "transcript":
        b = res["best"]
        print(f"verdict    : {res['verdict']}")
        print(f"similarity : {b['similarity']:.3f}")
        print(f"timestamp  : t={b['t']}s ({b['timestamp']}) — {b['speaker']}")
        print(f"line       : {b['text']}")
        print(f"why        : {b['significance']}")
    if res.get("note"):
        print(f"\nNOTE: {res['note']}")


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description="Provenance verifier — Looking Glass dossier.")
    p.add_argument("--json", action="store_true", help="emit JSON")
    sub = p.add_subparsers(dest="cmd", required=True)

    a = sub.add_parser("archive", help="Internet Archive capture history for a URL")
    a.add_argument("url")
    a.add_argument("--limit", type=int, default=500)

    pl = sub.add_parser("playlist", help="YouTube playlist metadata via yt-dlp")
    pl.add_argument("url")

    d = sub.add_parser("describe", help="analyse a video description for signals")
    d.add_argument("text", nargs="?")
    d.add_argument("-f", "--file")

    t = sub.add_parser("transcript", help="match a snippet against the 2012 corpus")
    t.add_argument("text", nargs="?")
    t.add_argument("-f", "--file")
    t.add_argument("--corpus")

    al = sub.add_parser("all", help="archive + playlist (if a playlist URL) for one URL")
    al.add_argument("url")

    args = p.parse_args(argv)

    if args.cmd == "archive":
        results = [archive_captures(args.url, args.limit)]
    elif args.cmd == "playlist":
        results = [playlist_info(args.url)]
    elif args.cmd == "describe":
        results = [analyze_description(_read_text(args.text, args.file))]
    elif args.cmd == "transcript":
        results = [transcript_match(_read_text(args.text, args.file), load_corpus(args.corpus))]
    else:
        results = [archive_captures(args.url)]
        if "list=" in args.url or "/playlist" in args.url:
            results.append(playlist_info(args.url))

    if args.json:
        print(json.dumps(results if len(results) > 1 else results[0], indent=2))
    else:
        for r in results:
            _print_human(r)
            print()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
