#!/usr/bin/env python3
"""
drift-check.py — Fail the build if marketing claims diverge from reality.

Two independent drift checks:

  1. PRODUCT drift  — does docs/claims-register.json still match what the
     product actually contains right now? (regenerates in-memory and diffs)

  2. SITE drift     — does the live site (https://donjonsec.com) still state
     the same provable numbers the register claims? Fetches the rendered HTML
     and looks for the register's marketing strings / values.

Designed to run in CI. EXITS NONZERO on any drift so the site can never
silently diverge from the product again.

Usage:
    python scripts/drift-check.py                 # both checks
    python scripts/drift-check.py --product-only   # skip the network fetch
    python scripts/drift-check.py --url URL        # override site URL
    python scripts/drift-check.py --allow-offline   # treat fetch failure as
                                                     # SKIP, not failure (air-gap CI)

Exit codes:
    0  no drift
    2  product drift (register stale vs product) -> run gen-claims.py --write
    3  site drift (live site disagrees with register)
    4  could not reach the site (and --allow-offline not set)
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
REGISTER_JSON = REPO_ROOT / "docs" / "claims-register.json"
DEFAULT_URL = "https://donjonsec.com"
USER_AGENT = "DonjonSec-DriftCheck/1.0"


def load_register() -> dict:
    if not REGISTER_JSON.exists():
        print(f"ERROR: register not found: {REGISTER_JSON}", file=sys.stderr)
        print("Run: python scripts/gen-claims.py --write", file=sys.stderr)
        sys.exit(2)
    return json.loads(REGISTER_JSON.read_text(encoding="utf-8"))


# --------------------------------------------------------------------------
# Check 1: product drift
# --------------------------------------------------------------------------
def check_product_drift(register: dict) -> bool:
    """Return True if the register is stale vs the live product."""
    import importlib.util

    spec = importlib.util.spec_from_file_location("gen_claims", SCRIPT_DIR / "gen-claims.py")
    gc = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(gc)
    fresh = gc.build_claims()

    a = dict(fresh); a.pop("generated_at", None)
    b = dict(register); b.pop("generated_at", None)

    if a == b:
        print("[product] OK — register matches the product.")
        return False

    print("[product] DRIFT — register is stale vs the product:", file=sys.stderr)
    fa, fb = a.get("provable", {}), b.get("provable", {})
    for key in sorted(set(fa) | set(fb)):
        va = fa.get(key, {}).get("value")
        vb = fb.get(key, {}).get("value")
        if va != vb:
            print(f"    {key}: product={va!r}  register={vb!r}", file=sys.stderr)
    # framework table diff
    ta = {r["id"]: r["controls"] for r in a.get("framework_table", [])}
    tb = {r["id"]: r["controls"] for r in b.get("framework_table", [])}
    for fid in sorted(set(ta) | set(tb)):
        if ta.get(fid) != tb.get(fid):
            print(f"    framework {fid}: product={ta.get(fid)!r}  register={tb.get(fid)!r}",
                  file=sys.stderr)
    print("    Fix: python scripts/gen-claims.py --write", file=sys.stderr)
    return True


# --------------------------------------------------------------------------
# Check 2: site drift
# --------------------------------------------------------------------------
def fetch_site(url: str) -> str:
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=30) as resp:  # nosec B310 - fixed https URL
        charset = resp.headers.get_content_charset() or "utf-8"
        return resp.read().decode(charset, errors="replace")


def _numbers_in(html: str) -> set[int]:
    """All integers appearing in the page, with thousands separators removed."""
    nums = set()
    for m in re.findall(r"\d[\d,]*", html):
        try:
            nums.add(int(m.replace(",", "")))
        except ValueError:
            pass
    return nums


def check_site_drift(register: dict, html: str) -> bool:
    """Return True if the live site disagrees with the register's provable numbers."""
    p = register["provable"]
    drift = False
    page_nums = _numbers_in(html)

    # Under-promise policy: the site must state an HONEST FLOOR of the CVE count
    # (e.g. "320K+" when the real count is 324,593). PASS any "<N>K+" whose
    # N*1000 does not exceed the real count; FAIL an over-claim (e.g. "327K+").
    cve = p["cve_signatures"]["value"]
    cve_max_k = cve // 1000  # ceiling for an honest floor, e.g. 324
    m = re.search(r"(\d{2,4})\s*K\+?\s*</p>\s*<p[^>]*>\s*CVE\s*Signatures", html, re.IGNORECASE)
    if not m:  # fallback: any "<N>K+ ... CVE" phrasing near the count
        m = re.search(r"(\d{2,4})\s*K\+?[^<]{0,40}CVE", html, re.IGNORECASE)
    if not m:
        print(f"[site] DRIFT cve_signatures: no CVE-count claim found on page (register={cve:,})",
              file=sys.stderr)
        drift = True
    elif int(m.group(1)) > cve_max_k:
        print(f"[site] DRIFT cve_signatures: site claims {m.group(1)}K+ but real count is {cve:,} "
              f"-- claim must be an honest floor (<= {cve_max_k}K)", file=sys.stderr)
        drift = True
    else:
        print(f"[site] OK cve_signatures = {m.group(1)}K+ (honest floor; real {cve:,})")

    # Framework count must appear verbatim (e.g. "30 compliance frameworks" / "30 frameworks").
    fw = p["framework_count"]["value"]
    if re.search(rf"\b{fw}\b\s*(?:compliance\s*)?framework", html, re.IGNORECASE):
        print(f"[site] OK framework_count = {fw}")
    else:
        # Surface a wrong nearby value (the historical "31" bug).
        found = re.findall(r"\b(\d{1,3})\b\s*(?:compliance\s*)?frameworks?", html, re.IGNORECASE)
        print(f"[site] DRIFT framework_count: register={fw}; "
              f"site framework-adjacent numbers={sorted(set(found))}", file=sys.stderr)
        drift = True

    # Scanner count: the site historically says "15+"; the register is the truth (18).
    # We FAIL if the site states a specific scanner number that is neither the
    # true value nor an honest "N+" floor that does not exceed the truth.
    sc = p["scanner_count"]["value"]
    scanner_claims = re.findall(r"(\d{1,3})\s*\+?\s*(?:integrated\s*)?scanners?", html,
                                re.IGNORECASE)
    scanner_claims = sorted({int(x) for x in scanner_claims})
    if not scanner_claims:
        print(f"[site] OK scanner_count (no explicit scanner number on page; register={sc})")
    else:
        # Under-promise policy: an honest floor passes (e.g. "15+" while we ship 18).
        # Only an OVER-claim (stating MORE scanners than we ship) is drift.
        bad = [n for n in scanner_claims if n > sc]
        if bad:
            print(f"[site] DRIFT scanner_count: register={sc}; site OVERCLAIMS {bad}",
                  file=sys.stderr)
            drift = True
        else:
            print(f"[site] OK scanner_count: honest floor {scanner_claims} (real {sc})")

    return drift


# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", default=DEFAULT_URL)
    parser.add_argument("--product-only", action="store_true")
    parser.add_argument("--allow-offline", action="store_true",
                        help="treat a site-fetch failure as SKIP, not failure")
    args = parser.parse_args(argv)

    register = load_register()

    product_drift = check_product_drift(register)

    site_drift = False
    if not args.product_only:
        try:
            html = fetch_site(args.url)
        except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
            if args.allow_offline:
                print(f"[site] SKIP — could not reach {args.url}: {exc}")
            else:
                print(f"[site] ERROR — could not reach {args.url}: {exc}", file=sys.stderr)
                return 4
        else:
            site_drift = check_site_drift(register, html)

    if product_drift:
        return 2
    if site_drift:
        return 3
    print("\nNO DRIFT — claims, product, and live site agree.")
    return 0


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