"""Un comando: comprueba seis huellas (data/ y el anexo), corre el estudio y escribe outputs/.

Lo primero que hace es comparar los cinco archivos de datos y el anexo sellado con las huellas
fijadas en este código (PINNED_SHA256) y los cinco archivos con SHA256SUMS: si un solo byte de
esos seis archivos difiere, o falta uno, o SHA256SUMS no los lista exactamente, el comando se
detiene con el código de salida 4 (EXIT_CHECKSUM) y no interpreta, calcula ni escribe nada (otros
archivos de data/ no se vigilan). Los bytes que se verificaron son los mismos que se interpretan
después. Corre las cuatro variantes y la referencia en los tres escenarios de costos, más las dos
corridas de atribución, todo sobre la misma ventana (protocolo §8). Calcula todo en memoria, escribe
las salidas en un directorio temporal junto a outputs/ y solo al final las mueve, con run-log.json
en último lugar: un fallo antes de ese momento no toca outputs/, y la presencia de run-log.json
marca una corrida completa.

Uso, desde la raíz del repositorio:

    python studies/estudio-01/run_study.py
    python studies/estudio-01/run_study.py --tables-only   # solo las dos tablas derivadas

Opciones: --data-dir y --out-dir (por defecto data/ y outputs/ junto a este archivo) y
--run-utc, que fija la hora que se anota en run-log.json para poder reproducir byte a byte.
"""

from __future__ import annotations

import argparse
import hashlib
import importlib.metadata
import math
import os
import re
import shutil
import sys
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, date, datetime
from pathlib import Path

HERE = Path(__file__).resolve().parent
if str(HERE) not in sys.path:  # con python -I el directorio del script no entra solo
    sys.path.insert(0, str(HERE))

import numpy as np  # noqa: E402
from estudio01 import data_h10, engine, metrics, rates, report  # noqa: E402

CANONICAL = (
    "01-h10-daily-rates.csv",
    "02-h10-hist-euro.htm",
    "03-fomc-openmarket.htm",
    "04-fomc-openmarket-archive.htm",
    "05-ecb-dfr.csv",
)
ANNEX_NAME = "anexo-datos-2026-10-22.md"
# Huellas sha256 de los cinco archivos de datos (las de la tabla «Archivos descargados» del anexo
# de datos) y del anexo mismo (la que se publicó en el sello del 2026-10-23, debb30d7...). Ningún
# archivo de data/ las define: si uno difiere, el comando se detiene antes de interpretarlo.
PINNED_SHA256 = {
    "01-h10-daily-rates.csv": "cb124b7f38f679860fdfda908f3dd395608753a7c7abbdfbe6c770e1f07c58e3",
    "02-h10-hist-euro.htm": "7505bd36c3ee64ab1415120e334b845840ae771a230a3e4d6924ef457a9458fc",
    "03-fomc-openmarket.htm": "0a08eb31c7b7c0b6ad9e0947bf0c12722ca19d1d803d1ed0a482698bf27bd9ab",
    "04-fomc-openmarket-archive.htm": (
        "0f2b1b361bc6d197a378cc6ff8b62fea8cf1f0992e8dcfe93f3e059cca4833ff"
    ),
    "05-ecb-dfr.csv": "3b8163aa7331ea6fc41fbf8dbb9833de64b40f6cec4977efbb9bac834b7946af",
    ANNEX_NAME: "debb30d7ae5960c6d0859d25309e058c253c72e3fed2d90aee29c0b447528a0f",
}
TABLE_FILES = ("fomc-decisiones.csv", "ecb-dfr.csv")
FINAL_START = date(2024, 10, 1)  # protocolo §3
FINAL_END = date(2026, 9, 30)  # protocolo §3
N_TRIALS = 4  # protocolo §5
BASE = next(scenario for scenario in engine.SCENARIOS if scenario.name == "base")
DSR_COLUMN = "DSR ≥ 0.95"
UTC_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")

EXIT_CHECKSUM = 4  # distinto de 0, 1 (error), 2 (uso de argparse) y de todo otro EXIT_*
EXIT_OUTPUTS_NOT_EMPTY = 3


class ChecksumError(Exception):
    """Un archivo de datos o el anexo no coincide con su huella (o SHA256SUMS no es válido)."""


@dataclass(frozen=True)
class DataFiles:
    """Los bytes de los cinco archivos y del anexo, leídos una sola vez y ya verificados."""

    blobs: dict[str, bytes]
    sha256: dict[str, str]


def read_verified(data_dir: Path, pinned: Mapping[str, str] | None = None) -> DataFiles:
    """Lee los cinco archivos y el anexo una sola vez y los compara con sus huellas.

    Cada archivo, que debe ser un archivo regular, se compara con su huella fijada en el código
    (PINNED_SHA256) y después los cinco archivos de datos con SHA256SUMS, que debe listar
    exactamente esos cinco (el anexo no va en SHA256SUMS). Un byte distinto, un archivo ausente o
    que no es regular, o un SHA256SUMS inválido detienen el comando. Quien llama recibe los mismos
    bytes que se hashearon: nada vuelve a abrir los archivos. `pinned` solo lo usan las pruebas,
    con datos sintéticos; la línea de comandos siempre usa PINNED_SHA256.
    """
    pinned = PINNED_SHA256 if pinned is None else pinned
    blobs: dict[str, bytes] = {}
    digests: dict[str, str] = {}
    for name in (*CANONICAL, ANNEX_NAME):
        path = data_dir / name
        if not path.is_file():  # una FIFO o un dispositivo colgaría o agotaría la lectura
            raise ChecksumError(f"{name} no existe o no es un archivo regular")
        try:
            blobs[name] = path.read_bytes()
        except OSError as error:
            raise ChecksumError(f"no se puede leer {name}: {error}") from error
        digests[name] = hashlib.sha256(blobs[name]).hexdigest()
    different = [name for name in blobs if digests[name] != pinned.get(name)]
    if different:
        raise ChecksumError(f"el sha256 no coincide con la huella fijada en el código: {different}")

    try:
        text = (data_dir / "SHA256SUMS").read_text(encoding="ascii")
    except (OSError, UnicodeDecodeError) as error:
        raise ChecksumError(f"no se puede leer SHA256SUMS: {error}") from error
    expected: dict[str, str] = {}
    for line in text.splitlines():
        digest, separator, name = line.partition("  ")
        if not separator or not re.fullmatch(r"[0-9a-f]{64}", digest) or not name:
            raise ChecksumError(f"línea no válida en SHA256SUMS: {line!r}")
        if name in expected:
            raise ChecksumError(f"SHA256SUMS lista dos veces {name}")
        expected[name] = digest
    if set(expected) != set(CANONICAL):
        raise ChecksumError(f"SHA256SUMS debe listar exactamente {sorted(CANONICAL)}")
    for name in CANONICAL:
        if digests[name] != expected[name]:
            raise ChecksumError(f"{name}: el sha256 no coincide con SHA256SUMS")
    return DataFiles(blobs, digests)


@dataclass(frozen=True)
class Inputs:
    bars: data_h10.Bars
    ecb: tuple[rates.EcbObservation, ...]
    decisions: tuple[rates.Decision, ...]
    r_eur: list[float]
    r_usd: list[float]


def load_inputs(files: DataFiles) -> Inputs:
    """Interpreta los bytes ya verificados (UTF-8 estricto); no abre ningún archivo."""
    bars = data_h10.parse_h10(files.blobs["01-h10-daily-rates.csv"].decode("utf-8"))
    ecb = rates.parse_ecb(files.blobs["05-ecb-dfr.csv"].decode("utf-8"))
    pages = [
        rates.parse_fomc(files.blobs[name].decode("utf-8"))
        for name in ("03-fomc-openmarket.htm", "04-fomc-openmarket-archive.htm")
    ]
    decisions = rates.merge_decisions(*pages)
    r_eur, r_usd = rates.rates_for_dates(bars.dates, ecb, decisions)
    return Inputs(bars, ecb, decisions, r_eur, r_usd)


@dataclass(frozen=True)
class Results:
    runs: dict[tuple[int, str], engine.Run]
    reference: dict[str, engine.Run]
    no_spread: engine.Run
    no_swap: engine.Run
    window: dict[tuple[int, str], metrics.RunMetrics]
    final: dict[tuple[int, str], metrics.RunMetrics]
    ref_window: dict[str, metrics.RunMetrics]
    ref_final: dict[str, metrics.RunMetrics]
    dsr: dict[str, metrics.Dsr]
    pbo: metrics.Pbo


def compute(inputs: Inputs) -> Results:
    """Corre todas las configuraciones declaradas y sus métricas, sin escribir nada."""
    prices, days = list(inputs.bars.prices), list(inputs.bars.dates)
    variants = range(1, len(engine.EMA_PAIRS) + 1)
    targets = {
        variant: engine.pending_targets(engine.ema(prices, fast), engine.ema(prices, slow))
        for variant, (fast, slow) in zip(variants, engine.EMA_PAIRS, strict=True)
    }
    reference_targets = engine.reference_targets(len(prices))

    def run(target: list[int | None], spread: float, m: float, swap_on: bool = True) -> engine.Run:
        return engine.simulate(
            prices, days, target, inputs.r_eur, inputs.r_usd, spread, m, swap_on=swap_on
        )

    runs: dict[tuple[int, str], engine.Run] = {}
    reference: dict[str, engine.Run] = {}
    for scenario in engine.SCENARIOS:
        for variant in variants:
            runs[(variant, scenario.name)] = run(targets[variant], scenario.spread, scenario.m)
        reference[scenario.name] = run(reference_targets, scenario.spread, scenario.m)
    no_spread = run(targets[1], 0.0, BASE.m)
    no_swap = run(targets[1], BASE.spread, BASE.m, swap_on=False)

    window = {key: metrics.run_metrics(value) for key, value in runs.items()}
    final = {key: metrics.run_metrics(value, FINAL_START, FINAL_END) for key, value in runs.items()}
    ref_window = {key: metrics.run_metrics(value) for key, value in reference.items()}
    ref_final = {
        key: metrics.run_metrics(value, FINAL_START, FINAL_END) for key, value in reference.items()
    }

    dsr: dict[str, metrics.Dsr] = {}
    for scenario in engine.SCENARIOS:
        sharpes = [window[(variant, scenario.name)].sharpe_per_bar for variant in variants]
        first = runs[(1, scenario.name)]
        skew, kurt = metrics.skew_kurtosis(first.ret)
        dsr[scenario.name] = metrics.deflated_sharpe(
            sharpes[0], len(first.ret), skew, kurt, metrics.sample_variance(sharpes), N_TRIALS
        )
    matrix = np.array([runs[(variant, BASE.name)].ret for variant in variants]).T
    return Results(
        runs,
        reference,
        no_spread,
        no_swap,
        window,
        final,
        ref_window,
        ref_final,
        dsr,
        metrics.pbo_cscv(matrix),
    )


def write_tables(out_dir: Path, inputs: Inputs) -> list[str]:
    """Las dos tablas derivadas, tal como las parseó el código: fecha y nivel."""
    report.write_csv(
        out_dir / "fomc-decisiones.csv",
        ("date", "date_as_published", "level_as_published", "target_fraction", "note"),
        (
            (str(item.date), item.date_text, item.level_text, report.fmt(item.target), item.note)
            for item in inputs.decisions
        ),
    )
    report.write_csv(
        out_dir / "ecb-dfr.csv",
        ("date", "obs_value_percent", "rate_fraction"),
        ((str(item.date), item.percent_text, report.fmt(item.rate)) for item in inputs.ecb),
    )
    return list(TABLE_FILES)


def _metric_cells(item: metrics.RunMetrics) -> list[str | int]:
    return [
        report.fmt(item.sharpe_annualized),
        report.fmt(item.sharpe_per_bar),
        report.fmt(item.total_return),
        report.fmt(item.max_drawdown),
        item.executions,
        report.fmt(100.0 * item.fraction_in_position),
        item.n_returns,
        report.fmt(item.fixes_per_year),
    ]


def zero_deviation_labels(results: Results) -> list[str]:
    """Corridas cuya desviación estándar fue 0: su Sharpe se reporta como 0 y se declara (§6)."""
    return sorted(
        [f"v{v}/{n}" for (v, n), item in results.window.items() if item.zero_deviation]
        + [f"v{v}/{n}/tramo-final" for (v, n), item in results.final.items() if item.zero_deviation]
        + [f"ref/{n}" for n, item in results.ref_window.items() if item.zero_deviation]
        + [f"ref/{n}/tramo-final" for n, item in results.ref_final.items() if item.zero_deviation]
    )


def write_all(
    out_dir: Path, inputs: Inputs, results: Results, files: DataFiles, run_utc: str
) -> list[str]:
    names = write_tables(out_dir, inputs)
    pairs = {i: f"{fast}/{slow}" for i, (fast, slow) in enumerate(engine.EMA_PAIRS, 1)}
    scenarios = [scenario.name for scenario in engine.SCENARIOS]
    key_order = [(variant, name) for name in scenarios for variant in pairs]

    report.write_csv(
        out_dir / "metrics.csv",
        (
            "variant",
            "ema_pair",
            "scenario",
            "sharpe_annualized",
            "sharpe_per_bar",
            "total_return",
            "max_drawdown",
            "executions",
            "pct_in_position",
            "T",
            "fixes_per_year",
        ),
        (
            [variant, pairs[variant], name, *_metric_cells(results.window[(variant, name)])]
            for variant, name in key_order
        ),
    )
    report.write_csv(
        out_dir / "dsr.csv",
        ("scenario", "N", "T", "sr_hat", "gamma3", "gamma4", "V", "SR0", "DSR", "PSR0", DSR_COLUMN),
        (
            [
                name,
                item.n_trials,
                item.n_returns,
                report.fmt(item.sr_hat),
                report.fmt(item.skew),
                report.fmt(item.kurt),
                report.fmt(item.variance),
                report.fmt(item.sr0),
                report.fmt(item.dsr),
                report.fmt(item.psr0),
                report.fmt_bool(item.passes),
            ]
            for name, item in results.dsr.items()
        ),
    )

    pbo = results.pbo
    pbo_rows: list[list[str | int]] = [
        ["pbo", "", "", "", report.fmt(pbo.pbo)],
        ["combinations", "", "", "", pbo.n_combinations],
        ["n_returns", "", "", "", pbo.n_returns],
        ["n_dropped", "", "", "", pbo.n_dropped],
        ["n_used", "", "", "", pbo.n_used],
        ["block_length", "", "", "", pbo.block_length],
    ]
    for omega, count in pbo.omega_counts:
        lam = math.log(omega / (1.0 - omega))
        pbo_rows.append(["lambda_distribution", report.fmt(omega), report.fmt(lam), count, ""])
    report.write_csv(out_dir / "pbo.csv", ("item", "omega", "lambda", "count", "value"), pbo_rows)

    report.write_csv(
        out_dir / "tramo-final.csv",
        (
            "variant",
            "ema_pair",
            "scenario",
            "bars",
            "total_return",
            "sharpe_annualized",
            "max_drawdown",
            "executions",
        ),
        (
            [
                variant,
                pairs[variant],
                name,
                results.final[(variant, name)].n_returns,
                report.fmt(results.final[(variant, name)].total_return),
                report.fmt(results.final[(variant, name)].sharpe_annualized),
                report.fmt(results.final[(variant, name)].max_drawdown),
                results.final[(variant, name)].executions,
            ]
            for variant, name in key_order
        ),
    )
    base_total = results.window[(1, BASE.name)].total_return
    report.write_csv(
        out_dir / "atribucion.csv",
        ("item", "total_return_base", "total_return_alt", "difference_base_minus_alt"),
        (
            [label, report.fmt(base_total), report.fmt(alt), report.fmt(base_total - alt)]
            for label, alt in (
                ("no_spread", metrics.total_return(results.no_spread.ret)),
                ("no_swap", metrics.total_return(results.no_swap.ret)),
            )
        ),
    )
    report.write_csv(
        out_dir / "referencia.csv",
        (
            "scenario",
            "sharpe_annualized",
            "sharpe_per_bar",
            "total_return",
            "max_drawdown",
            "executions",
            "pct_in_position",
            "T",
            "fixes_per_year",
            "final_bars",
            "final_total_return",
            "final_sharpe_annualized",
            "final_max_drawdown",
            "final_executions",
        ),
        (
            [
                name,
                *_metric_cells(results.ref_window[name]),
                results.ref_final[name].n_returns,
                report.fmt(results.ref_final[name].total_return),
                report.fmt(results.ref_final[name].sharpe_annualized),
                report.fmt(results.ref_final[name].max_drawdown),
                results.ref_final[name].executions,
            ]
            for name in scenarios
        ),
    )

    for variant, name in key_order:
        item = results.runs[(variant, name)]
        report.write_csv(
            out_dir / f"equity-v{variant}-{name}.csv",
            ("date", "P", "d", "K_minus", "K_plus", "S", "c", "R"),
            (
                [
                    str(item.dates[i]),
                    report.fmt(item.prices[i]),
                    item.d[i],
                    report.fmt(item.k_minus[i]),
                    report.fmt(item.k_plus[i]),
                    report.fmt(item.swap[i]),
                    report.fmt(item.cost[i]),
                    report.fmt(item.ret[i]),
                ]
                for i in range(len(item.dates))
            ),
        )
        names.append(f"equity-v{variant}-{name}.csv")
    names += [
        "metrics.csv",
        "dsr.csv",
        "pbo.csv",
        "tramo-final.csv",
        "atribucion.csv",
        "referencia.csv",
    ]

    versions = {"python": sys.version.split()[0]}
    for package in ("numpy", "pandas"):
        try:
            versions[package] = importlib.metadata.version(package)
        except importlib.metadata.PackageNotFoundError:
            versions[package] = "not installed"
    zero_deviation = zero_deviation_labels(results)
    report.write_json(
        out_dir / "run-log.json",
        {
            "run_utc": run_utc,
            "versions": versions,
            "n_trials": N_TRIALS,
            "zero_deviation_sharpe_reported_as_0": zero_deviation,
            "h10": {
                "bars": len(inputs.bars.prices),
                "skipped": [[str(day), raw] for day, raw in inputs.bars.skipped],
            },
            "data_sha256": {name: files.sha256[name] for name in sorted(CANONICAL)},
            "annex_sha256": {ANNEX_NAME: files.sha256[ANNEX_NAME]},
            "outputs_sha256": {name: report.sha256_of(out_dir / name) for name in sorted(names)},
        },
    )
    return [*sorted(names), "run-log.json"]


def publish(staging: Path, out_dir: Path, names: list[str]) -> None:
    """Mueve los archivos ya escritos al directorio de salida; run-log.json, si existe, al final."""
    out_dir.mkdir(parents=True, exist_ok=True)
    ordered = sorted(name for name in names if name != "run-log.json")
    ordered += ["run-log.json"] if "run-log.json" in names else []
    for name in ordered:
        os.replace(staging / name, out_dir / name)


def build_parser() -> argparse.ArgumentParser:
    """Las únicas opciones de la línea de comandos; ninguna sustituye las huellas fijadas."""
    parser = argparse.ArgumentParser(description=next(iter((__doc__ or "").splitlines()), ""))
    parser.add_argument("--data-dir", type=Path, default=HERE / "data")
    parser.add_argument("--out-dir", type=Path, default=HERE / "outputs")
    parser.add_argument("--run-utc", help="hora UTC AAAA-MM-DDTHH:MM:SSZ para run-log.json")
    parser.add_argument("--tables-only", action="store_true", help="solo fomc-decisiones y ecb-dfr")
    return parser


def main(argv: list[str] | None = None, *, pinned: Mapping[str, str] | None = None) -> int:
    """Punto de entrada. `pinned` es para las pruebas; ninguna opción de la línea de comandos lo
    sustituye: la corrida real siempre se compara con PINNED_SHA256."""
    parser = build_parser()
    args = parser.parse_args(argv)
    run_utc = args.run_utc or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
    if not UTC_STAMP.fullmatch(run_utc):
        parser.error("--run-utc debe tener la forma AAAA-MM-DDTHH:MM:SSZ")

    try:
        files = read_verified(args.data_dir, pinned)
    except ChecksumError as error:
        print(f"DETENIDO: {error}", file=sys.stderr)
        return EXIT_CHECKSUM
    if args.out_dir.exists() and (
        not args.out_dir.is_dir()
        or any(not (item.is_file() and item.name in TABLE_FILES) for item in args.out_dir.iterdir())
    ):
        print(f"DETENIDO: {args.out_dir} ya existe y no está vacío", file=sys.stderr)
        return EXIT_OUTPUTS_NOT_EMPTY

    inputs = load_inputs(files)
    results = None if args.tables_only else compute(inputs)  # todo en memoria, sin escribir aún
    args.out_dir.parent.mkdir(parents=True, exist_ok=True)
    staging = Path(tempfile.mkdtemp(prefix=".outputs-staging-", dir=args.out_dir.parent))
    try:
        if results is None:
            names = write_tables(staging, inputs)
            summary = (
                f"{len(inputs.decisions)} decisiones del FOMC y {len(inputs.ecb)} del BCE: {names}"
            )
        else:
            names = write_all(staging, inputs, results, files, run_utc)
            summary = f"{len(inputs.bars.prices)} barras; salidas escritas en {args.out_dir}"
        publish(staging, args.out_dir, names)
    finally:
        shutil.rmtree(staging, ignore_errors=True)
    print(summary)
    return 0


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