#!/usr/bin/env python3
"""Freeze or verify the aggregate-only EPL LightGBM research backtest.

The default command is intentionally fast: it checks the committed artifact
bytes, hashes, source pins, generator hash and activation boundary. Use
``--rebuild`` to download the ten hash-pinned Football-Data CSVs and rerun the
existing ``model/proofxi_ml`` feature/training pipeline. ``--write`` performs
that full rebuild before replacing the immutable v1 aggregate files.

No source rows, fixture-level forecasts, fitted model or calibrator are
published by this generator.
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import importlib.metadata
import io
import json
import sys
import tempfile
import urllib.request
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
VERSION = "lightgbm-football-prediction-model-backtest/1.0.0"
RELEASE_DATE = "2026-07-18"
SOURCE_MANIFEST_PATH = ROOT / "data/football-1x2-empirical-benchmark-1.0.0-manifest.json"
JSON_PATH = ROOT / "data/lightgbm-football-prediction-model-backtest-1.0.0.json"
CSV_PATH = ROOT / "data/lightgbm-football-prediction-model-backtest-1.0.0.csv"
MANIFEST_PATH = ROOT / "data/lightgbm-football-prediction-model-backtest-1.0.0-manifest.json"
GENERATOR_PATH = Path(__file__).resolve()
PUBLIC_JSON_PATH = "/research/lightgbm-football-prediction-model-backtest/1.0.0.json"
PUBLIC_CSV_PATH = "/downloads/lightgbm-football-prediction-model-backtest-1.0.0.csv"
PUBLIC_MANIFEST_PATH = "/research/lightgbm-football-prediction-model-backtest/1.0.0-manifest.json"
PUBLIC_GENERATOR_PATH = "/downloads/generate-lightgbm-football-prediction-model-backtest-1.0.0.py"
EXPECTED = {
    "sourceFixtureCount": 3800,
    "evaluationMatchCount": 2075,
    "evaluationStart": "2021-02-02",
    "evaluationEnd": "2026-05-24",
    "hitRate": 0.4930120482,
    "brierClassAverage": 0.2053958123,
    "logLossNats": 1.0796747069,
    "ece": 0.0340522168,
    "foldCount": 3,
}
PACKAGE_VERSIONS = {
    "joblib": "1.5.2",
    "lightgbm": "4.6.0",
    "numpy": "2.2.6",
    "scikit-learn": "1.7.2",
}


def sha256(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def canonical_json(value: Any) -> bytes:
    return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")


def iso_date(timestamp: int) -> str:
    return datetime.fromtimestamp(timestamp, timezone.utc).date().isoformat()


def rounded(value: Any) -> float:
    return round(float(value), 10)


def source_contract() -> tuple[list[dict[str, Any]], str]:
    source_bytes = SOURCE_MANIFEST_PATH.read_bytes()
    manifest = json.loads(source_bytes)
    inputs = manifest.get("sourceInputs")
    if not isinstance(inputs, list) or len(inputs) != 10:
        raise RuntimeError("The empirical benchmark must declare exactly ten source inputs.")
    selected = []
    for entry in inputs:
        selected.append(
            {
                "season": entry["season"],
                "role": entry["role"],
                "url": entry["url"],
                "localRelativePath": entry["url"].split("/mmz4281/", 1)[1],
                "sha256": entry["sha256"],
                "completedMatchRows": entry["completedMatchRows"],
                "fieldsUsed": ["Date", "Time", "HomeTeam", "AwayTeam", "FTHG", "FTAG", "FTR"],
            }
        )
    return selected, sha256(source_bytes)


def parse_timestamp(date_value: str, time_value: str) -> int:
    parts = [int(value) for value in date_value.split("/")]
    if len(parts) != 3:
        raise ValueError(f"invalid source date {date_value!r}")
    day, month, raw_year = parts
    year = raw_year + 2000 if raw_year < 100 else raw_year
    hour, minute = (int(value) for value in (time_value or "12:00").split(":")[:2])
    return int(datetime(year, month, day, hour, minute, tzinfo=timezone.utc).timestamp())


def read_source(entry: dict[str, Any], source_dir: Path | None) -> bytes:
    if source_dir is not None:
        value = (source_dir / entry["localRelativePath"]).read_bytes()
    else:
        request = urllib.request.Request(
            entry["url"],
            headers={"User-Agent": "FootballProofAI-LightGBM-Backtest/1.0 (+https://footballproofai.com)"},
        )
        with urllib.request.urlopen(request, timeout=60) as response:
            value = response.read()
    digest = sha256(value)
    if digest != entry["sha256"]:
        raise RuntimeError(
            f"Source hash mismatch for {entry['season']}: expected {entry['sha256']}, got {digest}. "
            "Immutable v1 refuses changed publisher bytes."
        )
    return value


def load_fixtures(source_dir: Path | None) -> tuple[list[Any], list[dict[str, Any]], str]:
    sys.path.insert(0, str(ROOT / "model"))
    from proofxi_ml.domain import Fixture

    inputs, source_manifest_sha = source_contract()
    rows: list[tuple[dict[str, Any], dict[str, str], int]] = []
    team_names: set[str] = set()
    for season_index, entry in enumerate(inputs):
        source_bytes = read_source(entry, source_dir)
        reader = csv.DictReader(io.StringIO(source_bytes.decode("utf-8-sig")))
        season_rows = []
        for source_index, row in enumerate(reader, start=1):
            if row.get("FTR") not in {"H", "D", "A"}:
                continue
            if not row.get("HomeTeam") or not row.get("AwayTeam"):
                raise RuntimeError(f"Missing team identity in {entry['season']} row {source_index}.")
            season_rows.append((entry, row, source_index))
            team_names.update((row["HomeTeam"], row["AwayTeam"]))
        if len(season_rows) != entry["completedMatchRows"]:
            raise RuntimeError(
                f"{entry['season']} has {len(season_rows)} completed rows; expected {entry['completedMatchRows']}."
            )
        rows.extend(season_rows)

    team_ids = {name: index for index, name in enumerate(sorted(team_names), start=1)}
    fixtures = []
    for fixture_id, (entry, row, _) in enumerate(rows, start=1):
        season = int(entry["season"].split("/", 1)[0])
        fixtures.append(
            Fixture(
                fixture_id=fixture_id,
                league_id=39,
                season=season,
                kickoff_utc=parse_timestamp(row["Date"], row.get("Time") or "12:00"),
                home_team_id=team_ids[row["HomeTeam"]],
                away_team_id=team_ids[row["AwayTeam"]],
                home_name=row["HomeTeam"],
                away_name=row["AwayTeam"],
                status="FT",
                home_goals=int(row["FTHG"]),
                away_goals=int(row["FTAG"]),
            )
        )
    if len(fixtures) != EXPECTED["sourceFixtureCount"]:
        raise RuntimeError(f"Expected 3,800 fixtures, got {len(fixtures)}.")
    return fixtures, inputs, source_manifest_sha


def assert_runtime_versions() -> None:
    for package, expected in PACKAGE_VERSIONS.items():
        observed = importlib.metadata.version(package)
        if observed != expected:
            raise RuntimeError(f"{package} must be {expected} for release v1; found {observed}.")


def build_release(source_dir: Path | None) -> tuple[bytes, bytes, bytes]:
    assert_runtime_versions()
    fixtures, source_inputs, source_manifest_sha = load_fixtures(source_dir)
    from proofxi_ml.features import FEATURE_NAMES, FEATURE_SCHEMA_VERSION, FeatureEngine
    from proofxi_ml.training import (
        ALGORITHM,
        TrainingConfig,
        candidate_gate_failures,
        gate_failures,
        walk_forward_backtest,
    )

    examples, _ = FeatureEngine().build(fixtures)
    config = TrainingConfig(folds=3)
    report, _, _ = walk_forward_backtest(examples, config)
    observed = {
        "sourceFixtureCount": len(fixtures),
        "evaluationMatchCount": report["sampleCount"],
        "evaluationStart": iso_date(report["evaluationStartUtc"]),
        "evaluationEnd": iso_date(report["evaluationEndUtc"]),
        "hitRate": rounded(report["hitRate"]),
        "brierClassAverage": rounded(report["brier"]),
        "logLossNats": rounded(report["logLoss"]),
        "ece": rounded(report["ece"]),
        "foldCount": report["foldCount"],
    }
    if observed != EXPECTED:
        raise RuntimeError(f"Backtest result drifted: expected {EXPECTED!r}, got {observed!r}.")
    numeric_failures = gate_failures(report)
    candidate_failures = candidate_gate_failures(report)
    if numeric_failures or candidate_failures != ["pointInTimeProvenance is required"]:
        raise RuntimeError(
            f"Activation boundary drifted: numeric={numeric_failures!r}, candidate={candidate_failures!r}."
        )

    generator_sha = sha256(GENERATOR_PATH.read_bytes())
    artifact = {
        "schemaVersion": VERSION,
        "releaseDate": RELEASE_DATE,
        "title": "EPL LightGBM football prediction model walk-forward backtest",
        "status": "research-only; candidate activation failed closed",
        "directAnswer": (
            "The fixed 14-feature LightGBM plus one-vs-rest isotonic pipeline passed the declared numeric "
            "backtest thresholds on 2,075 walk-forward evaluations, but it is not eligible for production "
            "activation because the latest-state source CSVs do not contain correction observed-at revision history."
        ),
        "league": {"code": "E0", "name": "English Premier League", "country": "England"},
        "sourceFixtureCount": len(fixtures),
        "evaluation": {
            "matchCount": report["sampleCount"],
            "foldCount": report["foldCount"],
            "temporalCoverageStart": iso_date(report["evaluationStartUtc"]),
            "temporalCoverageEnd": iso_date(report["evaluationEndUtc"]),
            "protocol": report["protocol"],
            "metrics": {
                "hitRate": rounded(report["hitRate"]),
                "brierClassAverage": rounded(report["brier"]),
                "logLossNats": rounded(report["logLoss"]),
                "ece": rounded(report["ece"]),
            },
        },
        "model": {
            "algorithm": ALGORITHM,
            "featureSchemaVersion": FEATURE_SCHEMA_VERSION,
            "featureCount": len(FEATURE_NAMES),
            "featureNames": list(FEATURE_NAMES),
            "trainingConfig": asdict(config),
            "dependencyVersions": PACKAGE_VERSIONS,
        },
        "gates": {
            "numericBacktest": {
                "passed": True,
                "failures": [],
                "required": report["gate"]["required"],
            },
            "candidateActivation": {
                "passed": False,
                "failures": candidate_failures,
                "productionGateModified": False,
            },
        },
        "pointInTimeBoundary": {
            "sourceRevisionMode": "latest-state-publisher-csv",
            "sourceRevisionHistoryComplete": False,
            "correctionObservedAtComplete": False,
            "asOfReplayVerified": False,
            "explanation": (
                "The hash-pinned files preserve the publisher bytes fetched for this release, but they expose latest "
                "match state rather than a versioned record of when corrections became observable. This prevents "
                "candidate activation even though the numerical thresholds pass."
            ),
        },
        "source": {
            "publisher": "Football-Data.co.uk",
            "landingPage": "https://www.football-data.co.uk/data.php",
            "notes": "https://www.football-data.co.uk/notes.txt",
            "derivedFromManifest": {
                "repositoryPath": str(SOURCE_MANIFEST_PATH.relative_to(ROOT)),
                "sha256": source_manifest_sha,
            },
            "sourceInputs": source_inputs,
            "rawRowsRedistributed": False,
        },
        "reproducibility": {
            "generator": {
                "repositoryPath": str(GENERATOR_PATH.relative_to(ROOT)),
                "publicPath": PUBLIC_GENERATOR_PATH,
                "sha256": generator_sha,
            },
            "checkCommand": "python3 scripts/generate-lightgbm-football-prediction-model-backtest.py",
            "rebuildCommand": ".venv-model/bin/python scripts/generate-lightgbm-football-prediction-model-backtest.py --rebuild",
            "writeCommand": ".venv-model/bin/python scripts/generate-lightgbm-football-prediction-model-backtest.py --write",
        },
        "rights": {
            "aggregateLicense": "https://creativecommons.org/licenses/by/4.0/",
            "sourceRightsStatus": "unknown/not asserted",
            "rawSourceLicenseNotGranted": True,
            "rawRowsRedistributed": False,
        },
        "claimsBoundary": {
            "liveAccuracyClaim": False,
            "profitClaim": False,
            "bettingAdvice": False,
            "productionModelActivated": False,
            "modelArtifactPublished": False,
        },
        "limitations": [
            "This is an aggregate research backtest for one league and ten seasons, not live forecast accuracy.",
            "Latest-state CSVs lack correction observed-at revision history, so point-in-time provenance is incomplete and activation fails closed.",
            "No raw source rows, fixture-level probabilities, fitted model or calibrator are redistributed.",
            "Hit rate, Brier score, log loss and ECE do not establish betting profit, value or a universal edge.",
        ],
    }
    json_bytes = canonical_json(artifact)
    csv_text = (
        "scope,league,sourceFixtures,evaluationN,folds,evaluationStart,evaluationEnd,hitRate,brierClassAverage,logLossNats,ece,numericGatePassed,candidateActivationPassed,candidateActivationFailure\n"
        f"overall,E0,{len(fixtures)},{report['sampleCount']},{report['foldCount']},{iso_date(report['evaluationStartUtc'])},{iso_date(report['evaluationEndUtc'])},{rounded(report['hitRate']):.10f},{rounded(report['brier']):.10f},{rounded(report['logLoss']):.10f},{rounded(report['ece']):.10f},true,false,pointInTimeProvenance is required\n"
    )
    csv_bytes = csv_text.encode("utf-8")
    manifest = {
        "schemaVersion": "lightgbm-football-prediction-model-backtest-manifest/1.0.0",
        "benchmarkVersion": VERSION,
        "releaseDate": RELEASE_DATE,
        "rawRowsRedistributed": False,
        "publicManifestPath": PUBLIC_MANIFEST_PATH,
        "artifacts": [
            {"path": PUBLIC_JSON_PATH, "contentType": "application/json; charset=utf-8", "bytes": len(json_bytes), "sha256": sha256(json_bytes)},
            {"path": PUBLIC_CSV_PATH, "contentType": "text/csv; charset=utf-8", "bytes": len(csv_bytes), "sha256": sha256(csv_bytes)},
            {"path": PUBLIC_GENERATOR_PATH, "contentType": "text/x-python; charset=utf-8", "bytes": GENERATOR_PATH.stat().st_size, "sha256": generator_sha},
        ],
        "sourceManifest": {"path": str(SOURCE_MANIFEST_PATH.relative_to(ROOT)), "sha256": source_manifest_sha},
        "sourceInputs": source_inputs,
        "generator": {"path": str(GENERATOR_PATH.relative_to(ROOT)), "sha256": generator_sha},
    }
    return json_bytes, csv_bytes, canonical_json(manifest)


def verify_frozen() -> None:
    artifact_bytes = JSON_PATH.read_bytes()
    csv_bytes = CSV_PATH.read_bytes()
    manifest_bytes = MANIFEST_PATH.read_bytes()
    artifact = json.loads(artifact_bytes)
    manifest = json.loads(manifest_bytes)
    inputs, source_manifest_sha = source_contract()
    if artifact.get("evaluation", {}).get("metrics") != {
        "hitRate": EXPECTED["hitRate"],
        "brierClassAverage": EXPECTED["brierClassAverage"],
        "logLossNats": EXPECTED["logLossNats"],
        "ece": EXPECTED["ece"],
    }:
        raise RuntimeError("Frozen headline metrics do not match the reviewed report.")
    if artifact.get("gates", {}).get("candidateActivation", {}).get("failures") != ["pointInTimeProvenance is required"]:
        raise RuntimeError("Frozen candidate activation boundary is not fail-closed.")
    if manifest.get("sourceManifest", {}).get("sha256") != source_manifest_sha or manifest.get("sourceInputs") != inputs:
        raise RuntimeError("Frozen source pins drifted from the empirical benchmark manifest.")
    by_path = {entry["path"]: entry for entry in manifest.get("artifacts", [])}
    checks = {
        PUBLIC_JSON_PATH: artifact_bytes,
        PUBLIC_CSV_PATH: csv_bytes,
        PUBLIC_GENERATOR_PATH: GENERATOR_PATH.read_bytes(),
    }
    for path, value in checks.items():
        entry = by_path.get(path)
        if not entry or entry.get("bytes") != len(value) or entry.get("sha256") != sha256(value):
            raise RuntimeError(f"Frozen artifact hash mismatch: {path}")
    if manifest.get("generator", {}).get("sha256") != sha256(GENERATOR_PATH.read_bytes()):
        raise RuntimeError("Generator hash drifted from the frozen manifest.")
    print(
        "LightGBM research backtest frozen bytes verified: "
        + ", ".join(f"{path}={sha256(value)}" for path, value in checks.items())
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--check", action="store_true", help="verify committed bytes and hashes (default)")
    parser.add_argument("--rebuild", action="store_true", help="rerun the hash-pinned LightGBM backtest and compare")
    parser.add_argument("--write", action="store_true", help="rerun and write immutable aggregate artifacts")
    parser.add_argument("--source-dir", type=Path, help="directory containing user-held <season>/E0.csv files")
    args = parser.parse_args()
    if sum(bool(value) for value in (args.check, args.rebuild, args.write)) > 1:
        parser.error("choose only one of --check, --rebuild or --write")
    if not args.rebuild and not args.write:
        verify_frozen()
        return
    built = build_release(args.source_dir.resolve() if args.source_dir else None)
    if args.write:
        for path, value in zip((JSON_PATH, CSV_PATH, MANIFEST_PATH), built, strict=True):
            path.write_bytes(value)
            print(f"{path.relative_to(ROOT)} {sha256(value)}")
        return
    current = (JSON_PATH.read_bytes(), CSV_PATH.read_bytes(), MANIFEST_PATH.read_bytes())
    if current != built:
        raise RuntimeError("Full LightGBM rebuild drifted from the frozen aggregate release.")
    verify_frozen()


if __name__ == "__main__":
    main()
