#!/usr/bin/env python3
"""
Validation checks for APLD forecast-vs-actual ledger output.

Run:
    python validate_apld_forecast_actual.py

Inputs:
    apld_forecast_actual_manifest.csv
    apld_forecast_actual_rows.csv

Output:
    apld_forecast_actual_validation_report.md
"""

from __future__ import annotations

import csv
import re
from collections import Counter
from pathlib import Path
from urllib.parse import urlparse


OUT = Path(__file__).resolve().parent
ROWS_PATH = OUT / "apld_forecast_actual_rows.csv"
MANIFEST_PATH = OUT / "apld_forecast_actual_manifest.csv"
REPORT_PATH = OUT / "apld_forecast_actual_validation_report.md"

REQUIRED_FIELDS = [
    "company",
    "period",
    "metric_name",
    "raw_value",
    "normalized_value",
    "unit",
    "value_qualifier",
    "ownership_basis",
    "forecast_or_actual",
    "is_derived",
    "source_url",
    "source_location",
    "source_snippet",
    "snippet_support_status",
    "confidence",
    "caveat",
]

ALLOWED_CLASSIFICATIONS = {"forecast", "actual"}
ALLOWED_SNIPPET_STATUS = {"supported_regex_match", "manual_review_needed", "unsupported_no_regex_match"}
ALLOWED_CONFIDENCE = {"high", "medium", "low"}
URL_RE = re.compile(r"^https://")


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


def is_number(value: str) -> bool:
    try:
        float(value)
        return True
    except ValueError:
        return bool(re.match(r"^\d{4}(-Q[1-4]|-mid|-early)?(;.*)?$", value))


def validate(rows: list[dict], manifest: list[dict]) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []

    if len(rows) < 12:
        errors.append(f"row count {len(rows)} is below minimum 12")

    manifest_urls = {m["source_url"] for m in manifest}

    for i, row in enumerate(rows, start=2):
        for field in REQUIRED_FIELDS:
            if field not in row:
                errors.append(f"row {i}: missing field {field}")
            elif row[field] == "":
                errors.append(f"row {i}: blank required field {field}")

        if row.get("forecast_or_actual") not in ALLOWED_CLASSIFICATIONS:
            errors.append(f"row {i}: invalid forecast_or_actual {row.get('forecast_or_actual')}")

        if row.get("is_derived") not in {"true", "false"}:
            errors.append(f"row {i}: invalid is_derived {row.get('is_derived')}")

        if not URL_RE.match(row.get("source_url", "")):
            errors.append(f"row {i}: source_url is not https: {row.get('source_url')}")

        parsed = urlparse(row.get("source_url", ""))
        if not parsed.netloc:
            errors.append(f"row {i}: source_url has no network location")

        if row.get("source_url") not in manifest_urls:
            errors.append(f"row {i}: source_url not found in manifest")

        if row.get("snippet_support_status") not in ALLOWED_SNIPPET_STATUS:
            errors.append(f"row {i}: invalid snippet_support_status {row.get('snippet_support_status')}")

        if row.get("snippet_support_status") != "supported_regex_match":
            warnings.append(f"row {i}: snippet support is {row.get('snippet_support_status')}")

        if row.get("confidence") not in ALLOWED_CONFIDENCE:
            errors.append(f"row {i}: invalid confidence {row.get('confidence')}")

        unit = row.get("unit")
        if unit in {"USD_millions", "MW"} and not is_number(row.get("normalized_value", "")):
            errors.append(f"row {i}: normalized_value is not numeric for {unit}")

        if unit == "USD_millions" and any(token in row.get("normalized_value", "").lower() for token in ["billion", "$", ","]):
            errors.append(f"row {i}: USD_millions normalized_value not normalized")

        if unit == "MW" and any(token in row.get("normalized_value", "").lower() for token in ["mw", ","]):
            errors.append(f"row {i}: MW normalized_value not normalized")

    classifications = Counter(r["forecast_or_actual"] for r in rows)
    if classifications["forecast"] == 0 or classifications["actual"] == 0:
        errors.append("rows must include both forecast and actual classifications")

    metric_names = [r["metric_name"] for r in rows]
    required_metric_families = ["capex", "capacity", "revenue", "commencement", "lease"]
    for family in required_metric_families:
        if not any(family in name for name in metric_names):
            errors.append(f"missing required metric family: {family}")

    return errors, warnings


def write_report(rows: list[dict], errors: list[str], warnings: list[str]) -> None:
    status = "PASS" if not errors else "FAIL"
    classifications = Counter(r["forecast_or_actual"] for r in rows)
    units = Counter(r["unit"] for r in rows)

    lines = [
        "# APLD Forecast-vs-Actual Validation Report",
        "",
        f"Status: {status}",
        f"Rows validated: {len(rows)}",
        f"Forecast rows: {classifications.get('forecast', 0)}",
        f"Actual rows: {classifications.get('actual', 0)}",
        f"Units: {dict(units)}",
        "",
        "## Checks",
        "- Required fields present and non-empty.",
        "- Minimum row count >= 12.",
        "- forecast_or_actual constrained to forecast or actual.",
        "- Unit normalization checked for USD_millions and MW.",
        "- Provenance fields non-empty.",
        "- source_url must be https and appear in manifest.",
        "- snippet_support_status constrained and expected to be supported_regex_match.",
        "- Metric families include capex, capacity, revenue, commencement, and lease.",
        "",
        "## Errors",
    ]
    lines.extend([f"- {e}" for e in errors] or ["- None"])
    lines.append("")
    lines.append("## Warnings")
    lines.extend([f"- {w}" for w in warnings] or ["- None"])
    lines.append("")
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
    rows = read_csv(ROWS_PATH)
    manifest = read_csv(MANIFEST_PATH)
    errors, warnings = validate(rows, manifest)
    write_report(rows, errors, warnings)
    print("PASS" if not errors else "FAIL")
    if errors:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
