#!/usr/bin/env python3
"""Validate AMZN production artifacts: schema, row counts, https provenance, snippets."""

from __future__ import annotations

import argparse
import csv
import json
import re
import subprocess
import sys
import urllib.request
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
MIN_QUARTERS = 8
MIN_VINTAGE_ROWS = 16
USER_AGENT = "AMZN-forecast-vintage-validator/1.0 (research; contact@example.com)"
GUIDANCE_ORIGIN_QUARTER = "FY2025-FQ4"
GUIDANCE_TARGET = "FY2026"

VINTAGE_FIELDS = [
    "company",
    "ticker",
    "as_of_period",
    "target_period",
    "metric_name",
    "raw_value",
    "normalized_gw",
    "unit",
    "forecast_or_actual",
    "is_derived",
    "source_url",
    "source_location",
    "source_snippet",
    "snippet_support_status",
    "confidence",
    "caveat",
]

TIMESERIES_FIELDS = [
    "company",
    "ticker",
    "fiscal_quarter",
    "actual_gw",
    "latest_forecast_gw",
    "original_forecast_gw",
    "under_construction_gw",
    "capex_actual_usd_b",
    "capex_guidance_latest_usd_b",
    "capex_guidance_original_usd_b",
    "capex_actual_metric",
    "capex_actual_is_derived",
    "caveat",
]

REQUIRED_FILES = [
    "source_manifest.csv",
    "forecast_vintage.csv",
    "capacity_timeseries.csv",
    "company_gw_chart.html",
    "extractor.py",
    "build_timeseries.py",
    "validator.py",
]

EXPECTED_QUARTERS = [
    "FY2024-FQ1",
    "FY2024-FQ2",
    "FY2024-FQ3",
    "FY2024-FQ4",
    "FY2025-FQ1",
    "FY2025-FQ2",
    "FY2025-FQ3",
    "FY2025-FQ4",
    "FY2026-FQ1",
]

FORBIDDEN_TICKERS = {
    "MSFT",
    "ORCL",
    "EQIX",
    "DLR",
    "Microsoft",
    "Oracle",
    "Equinix",
    "Digital Realty",
}

MUST_VERIFY_SNIPPET = {
    ("FY2024-FQ4", "FY2025", "capex_guidance"),
    ("FY2025-FQ2", "FY2025", "capex_guidance"),
    ("FY2025-FQ3", "FY2025", "capex_guidance"),
    ("FY2025-FQ4", "FY2026", "capex_guidance"),
    ("FY2026-FQ1", "FY2026", "capex_guidance"),
    ("FY2025-FQ4", "FY2025-FQ4", "capex_actual"),
}


def load_csv(path: Path) -> list[dict[str, str]]:
    with path.open(encoding="utf-8") as handle:
        return list(csv.DictReader(handle))


def fail(errors: list[str]) -> int:
    report = {"status": "FAIL", "errors": errors}
    print(json.dumps(report, indent=2))
    return 1


def pass_report(checks: list[str]) -> int:
    report = {"status": "PASS", "checks": checks}
    print(json.dumps(report, indent=2))
    return 0


def period_key(p: str) -> tuple[int, int]:
    fy = int(p[2:6])
    fq = int(p.split("-FQ")[1])
    return (fy, fq)


def fetch_html(url: str) -> str:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=90) as response:
        return response.read().decode("utf-8", errors="replace")


def html_to_text(html: str) -> str:
    text = re.sub(r"<script[^>]*>.*?</script>", " ", html, flags=re.I | re.S)
    text = re.sub(r"<style[^>]*>.*?</style>", " ", html, flags=re.I | re.S)
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"&#\d+;", " ", text)
    return re.sub(r"\s+", " ", text)


def snippet_in_source(snippet: str, html: str) -> bool:
    if not snippet.strip():
        return False
    text = html_to_text(html)
    norm_snippet = re.sub(r"\s+", " ", snippet.strip())
    norm_text = re.sub(r"\s+", " ", text)
    if norm_snippet in norm_text:
        return True
    core = re.sub(r"\([^)]*\)", "", norm_snippet).strip()
    return len(core) >= 20 and core.lower() in norm_text.lower()


def scan_forbidden_content(path: Path) -> list[str]:
    errors: list[str] = []
    if not path.exists():
        return errors
    text = path.read_text(encoding="utf-8", errors="replace")
    for token in FORBIDDEN_TICKERS:
        if token in text:
            errors.append(f"{path.name} contains forbidden cross-ticker content: {token}")
    return errors


def validate_https_urls(rows: list[dict[str, str]], label: str) -> list[str]:
    errors: list[str] = []
    for i, row in enumerate(rows, start=2):
        url = row.get("source_url", "")
        if url and not url.startswith("https://"):
            errors.append(f"{label} row {i}: source_url must start with https:// ({url})")
    return errors


def validate_snippet_status(rows: list[dict[str, str]], label: str, sample_every: int = 3) -> list[str]:
    errors: list[str] = []
    for i, row in enumerate(rows):
        if i % sample_every != 0:
            continue
        status = row.get("snippet_support_status", "")
        if status != "verified_primary":
            errors.append(f"{label} sampled row {i+2}: snippet_support_status={status!r}")
        snippet = row.get("source_snippet", "").strip()
        if not snippet:
            errors.append(f"{label} sampled row {i+2}: empty source_snippet")
    return errors


def validate_snippet_integrity(rows: list[dict[str, str]]) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    checks: list[str] = []
    cache: dict[str, str] = {}
    verified = 0
    for row in rows:
        key = (row["as_of_period"], row["target_period"], row["metric_name"])
        if key not in MUST_VERIFY_SNIPPET:
            continue
        url = row["source_url"]
        snippet = row.get("source_snippet", "").strip()
        if not snippet:
            errors.append(f"snippet integrity {key}: empty source_snippet")
            continue
        if url not in cache:
            try:
                cache[url] = fetch_html(url)
            except Exception as exc:  # noqa: BLE001
                errors.append(f"snippet integrity {key}: fetch failed for {url}: {exc}")
                continue
        if not snippet_in_source(snippet, cache[url]):
            errors.append(
                f"snippet integrity {key}: snippet not found at {url}: {snippet[:80]}..."
            )
        else:
            verified += 1
    if verified:
        checks.append(f"snippet integrity verified at source_url for {verified} critical rows")
    return errors, checks


def validate_vintage(path: Path, manifest_urls: set[str]) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    checks: list[str] = []
    if not path.exists():
        return [f"Missing {path.name}"], checks
    rows = load_csv(path)
    if len(rows) < MIN_VINTAGE_ROWS:
        errors.append(f"forecast_vintage.csv needs >={MIN_VINTAGE_ROWS} rows, got {len(rows)}")
    else:
        checks.append(f"forecast_vintage rows={len(rows)} (>={MIN_VINTAGE_ROWS})")

    if rows:
        missing_cols = set(VINTAGE_FIELDS) - set(rows[0].keys())
        if missing_cols:
            errors.append(f"forecast_vintage missing columns: {sorted(missing_cols)}")

    as_of = {r["as_of_period"] for r in rows}
    if len(as_of) < MIN_QUARTERS:
        errors.append(f"forecast_vintage needs >={MIN_QUARTERS} as_of_period, got {len(as_of)}")
    else:
        checks.append(f"forecast_vintage as_of_periods={len(as_of)}")

    guidance = [r for r in rows if r["metric_name"] == "capex_guidance"]
    if len(guidance) < 6:
        errors.append(f"Need >=6 capex_guidance vintages, got {len(guidance)}")
    else:
        checks.append(f"capex_guidance vintages={len(guidance)}")

    fy26 = [r for r in guidance if r["target_period"] == GUIDANCE_TARGET]
    if len(fy26) < 2:
        errors.append(f"Need >=2 {GUIDANCE_TARGET} capex_guidance vintages, got {len(fy26)}")
    else:
        checks.append(f"{GUIDANCE_TARGET} capex_guidance vintages={len(fy26)}")

    for i, row in enumerate(rows, start=2):
        if row["ticker"] != "AMZN":
            errors.append(f"vintage row {i}: ticker must be AMZN")
        if row["normalized_gw"] not in ("", None):
            errors.append(f"vintage row {i}: normalized_gw must be blank for AMZN")
        if row["source_url"] not in manifest_urls:
            errors.append(f"vintage row {i}: source_url not in manifest")

    errors.extend(validate_https_urls(rows, "forecast_vintage"))
    errors.extend(validate_snippet_status(rows, "forecast_vintage"))
    snippet_errs, snippet_checks = validate_snippet_integrity(rows)
    errors.extend(snippet_errs)
    checks.extend(snippet_checks)
    return errors, checks


def validate_timeseries(path: Path) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    checks: list[str] = []
    if not path.exists():
        return [f"Missing {path.name}"], checks
    rows = load_csv(path)
    if len(rows) < MIN_QUARTERS:
        errors.append(f"capacity_timeseries.csv needs >={MIN_QUARTERS} rows, got {len(rows)}")
    else:
        checks.append(f"capacity_timeseries rows={len(rows)}")

    if rows:
        missing_cols = set(TIMESERIES_FIELDS) - set(rows[0].keys())
        if missing_cols:
            errors.append(f"capacity_timeseries missing columns: {sorted(missing_cols)}")

    quarters = [r["fiscal_quarter"] for r in rows]
    if quarters != EXPECTED_QUARTERS:
        errors.append(f"Unexpected fiscal_quarter sequence: {quarters}")

    for i, row in enumerate(rows, start=2):
        if row["ticker"] != "AMZN":
            errors.append(f"timeseries row {i}: ticker must be AMZN")
        for gw_col in ("actual_gw", "latest_forecast_gw", "original_forecast_gw", "under_construction_gw"):
            if row.get(gw_col, "") != "":
                errors.append(f"timeseries row {i}: {gw_col} must be blank for AMZN GW proxy")
        if not row.get("capex_actual_usd_b"):
            errors.append(f"timeseries row {i}: missing capex_actual_usd_b")

    pre_origin = [r for r in rows if period_key(r["fiscal_quarter"]) < period_key(GUIDANCE_ORIGIN_QUARTER)]
    for row in pre_origin:
        if row["capex_guidance_latest_usd_b"]:
            errors.append(
                f"{row['fiscal_quarter']}: latest guidance must be blank before {GUIDANCE_ORIGIN_QUARTER}"
            )
        if row["capex_guidance_original_usd_b"]:
            errors.append(
                f"{row['fiscal_quarter']}: original guidance must be blank before {GUIDANCE_ORIGIN_QUARTER}"
            )

    post_origin = [r for r in rows if period_key(r["fiscal_quarter"]) >= period_key(GUIDANCE_ORIGIN_QUARTER)]
    originals_post = {r["capex_guidance_original_usd_b"] for r in post_origin}
    if originals_post != {"200"}:
        errors.append(
            f"original guidance should be 200 from {GUIDANCE_ORIGIN_QUARTER} onward, got {originals_post}"
        )

    latest_by_q = {r["fiscal_quarter"]: r["capex_guidance_latest_usd_b"] for r in rows}
    expected_latest = {
        "FY2025-FQ4": "200",
        "FY2026-FQ1": "200",
    }
    for q, exp in expected_latest.items():
        if latest_by_q.get(q) != exp:
            errors.append(f"{q} latest guidance expected {exp}, got {latest_by_q.get(q)}")

    fy24 = next((r for r in rows if r["fiscal_quarter"] == "FY2024-FQ4"), None)
    if fy24 and fy24.get("capex_actual_metric") != "capex_actual_fy":
        errors.append("FY2024-FQ4 must use capex_actual_fy metric (annual, not quarterly)")

    checks.append("timeseries guidance rollup spot-checks OK")
    return errors, checks


def validate_chart(path: Path) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    checks: list[str] = []
    if not path.exists():
        return [f"Missing {path.name}"], checks
    text = path.read_text(encoding="utf-8")
    if "<svg" not in text:
        errors.append("company_gw_chart.html must contain SVG")
    if "GW-proxy caveat" not in text and "GW-proxy" not in text:
        errors.append("chart missing GW-proxy caveat banner")
    for series in ("Capex actual", "Latest FY2026", "Original FY2026"):
        if series not in text:
            errors.append(f"chart missing legend for: {series}")
    if "annual anchor" not in text.lower() and "FY2024 annual" not in text:
        errors.append("chart must visually distinguish FY2024 annual anchor from quarterly series")
    if "AMZN" not in text and "Amazon" not in text:
        errors.append("chart must reference AMZN/Amazon")
    checks.append("company_gw_chart.html structure OK")
    return errors, checks


def validate_extractor(prod_dir: Path) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    checks: list[str] = []
    extractor = prod_dir / "extractor.py"
    if not extractor.exists():
        return ["Missing extractor.py"], checks
    out = prod_dir / "_validator_forecast_vintage.csv"
    try:
        result = subprocess.run(
            [
                sys.executable,
                str(extractor),
                "--manifest",
                str(prod_dir / "source_manifest.csv"),
                "--output",
                str(out),
            ],
            capture_output=True,
            text=True,
            timeout=180,
            check=False,
        )
        if result.returncode != 0:
            errors.append(f"extractor.py failed: {result.stderr or result.stdout}")
            return errors, checks
        rows = load_csv(out)
        if len(rows) < MIN_VINTAGE_ROWS:
            errors.append(f"extractor regenerated {len(rows)} rows, need >={MIN_VINTAGE_ROWS}")
        else:
            checks.append(f"extractor regenerates {len(rows)} vintage rows")
        snippet_errs, snippet_checks = validate_snippet_integrity(rows)
        errors.extend(snippet_errs)
        checks.extend(snippet_checks)
        out.unlink(missing_ok=True)
    except subprocess.TimeoutExpired:
        errors.append("extractor.py timed out")
    return errors, checks


def validate_timeseries_build(prod_dir: Path) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    checks: list[str] = []
    build_script = prod_dir / "build_timeseries.py"
    if not build_script.exists():
        return ["Missing build_timeseries.py"], checks

    result = subprocess.run(
        [sys.executable, str(build_script)],
        capture_output=True,
        text=True,
        timeout=60,
        check=False,
        cwd=str(prod_dir),
    )
    if result.returncode != 0:
        errors.append(f"build_timeseries.py failed: {result.stderr or result.stdout}")
        return errors, checks

    rebuilt = load_csv(prod_dir / "capacity_timeseries.csv")
    if len(rebuilt) < MIN_QUARTERS:
        errors.append(f"build_timeseries produced {len(rebuilt)} rows, need >={MIN_QUARTERS}")
    else:
        checks.append(f"build_timeseries regenerates {len(rebuilt)} timeseries rows")
    return errors, checks


def main() -> int:
    parser = argparse.ArgumentParser(description="Validate AMZN production artifacts")
    parser.add_argument(
        "--dir",
        type=Path,
        default=BASE_DIR,
        help="Production AMZN directory (default: script directory)",
    )
    args = parser.parse_args()
    prod_dir = args.dir.resolve()

    all_errors: list[str] = []
    all_checks: list[str] = []

    for name in REQUIRED_FILES:
        if not (prod_dir / name).exists():
            all_errors.append(f"Missing required file: {name}")
        else:
            all_checks.append(f"present: {name}")
        if name.endswith((".csv", ".html")):
            all_errors.extend(scan_forbidden_content(prod_dir / name))

    manifest_path = prod_dir / "source_manifest.csv"
    manifest_urls: set[str] = set()
    if manifest_path.exists():
        manifest_rows = load_csv(manifest_path)
        manifest_urls = {r["source_url"] for r in manifest_rows}
        if len(manifest_rows) < MIN_QUARTERS:
            all_errors.append(f"source_manifest needs >={MIN_QUARTERS} rows, got {len(manifest_rows)}")
        else:
            all_checks.append(f"source_manifest rows={len(manifest_rows)}")
        for row in manifest_rows:
            if row["ticker"] != "AMZN":
                all_errors.append("manifest contains non-AMZN ticker")
            if not row["source_url"].startswith("https://"):
                all_errors.append(f"manifest bad url: {row['source_url']}")

    errs, checks = validate_vintage(prod_dir / "forecast_vintage.csv", manifest_urls)
    all_errors.extend(errs)
    all_checks.extend(checks)

    errs, checks = validate_timeseries(prod_dir / "capacity_timeseries.csv")
    all_errors.extend(errs)
    all_checks.extend(checks)

    errs, checks = validate_chart(prod_dir / "company_gw_chart.html")
    all_errors.extend(errs)
    all_checks.extend(checks)

    errs, checks = validate_extractor(prod_dir)
    all_errors.extend(errs)
    all_checks.extend(checks)

    errs, checks = validate_timeseries_build(prod_dir)
    all_errors.extend(errs)
    all_checks.extend(checks)

    if all_errors:
        return fail(all_errors)
    return pass_report(all_checks)


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