#!/usr/bin/env python3
"""Build chamber inputs from real observed-radius SPARC baryonic accelerations.

This script never interpolates. It writes the same sorted unique log10(g_bar)
vector to STRUC-I and STRUC-PERC-I input directories.
"""

from __future__ import annotations

import argparse
import hashlib
import re
from pathlib import Path

import numpy as np
import pandas as pd

REQUIRED_COLUMNS = {
    "galaxy_id", "radius_kpc", "g_bar", "g_obs", "valid_point",
    "quality_flag", "inclination_deg", "is_primary",
    "is_inclination_sensitivity", "is_resolution_sensitivity",
    "is_strict_sensitivity", "is_broad_q12", "is_q3_sensitivity",
}


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            h.update(block)
    return h.hexdigest()


def values_hash(values: np.ndarray) -> str:
    canonical = "\n".join(f"{value:.17g}" for value in values) + "\n"
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def safe_identifier(value: str) -> str:
    safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip()).strip("_.")
    if not safe:
        raise ValueError(f"Cannot create safe identifier from {value!r}")
    return safe


def iqr(values: np.ndarray) -> float:
    if len(values) == 0:
        return 0.0
    q1, q3 = np.quantile(values, [0.25, 0.75])
    return float(q3 - q1)


def load_profiles(path: Path) -> pd.DataFrame:
    frame = pd.read_csv(path, float_precision="round_trip")
    missing = REQUIRED_COLUMNS - set(frame.columns)
    if missing:
        raise ValueError(f"{path}: missing required columns {sorted(missing)}")
    if not frame["valid_point"].astype(bool).all():
        raise ValueError("Prepared profile file contains rows not marked valid")
    if frame.duplicated(["galaxy_id", "radius_kpc"]).any():
        raise ValueError("Duplicate galaxy-radius rows in prepared profile")
    return frame


def build_ladder(group: pd.DataFrame) -> tuple[np.ndarray, dict[str, float | int]]:
    gbar = pd.to_numeric(group["g_bar"], errors="coerce").to_numpy(dtype=float)
    radius = pd.to_numeric(group["radius_kpc"], errors="coerce").to_numpy(dtype=float)
    gobs = pd.to_numeric(group["g_obs"], errors="coerce").to_numpy(dtype=float)
    mask = np.isfinite(gbar) & np.isfinite(radius) & np.isfinite(gobs) & (gbar > 0) & (radius > 0) & (gobs > 0)
    if not mask.all():
        raise ValueError("invalid row reached ladder builder")
    ladder = np.unique(np.sort(np.log10(gbar)))
    if len(ladder) < 3:
        raise ValueError(f"only {len(ladder)} unique observed values; chamber minimum is 3")
    gaps = np.diff(ladder)
    stats = {
        "observed_radial_points": int(len(group)),
        "unique_ladder_values": int(len(ladder)),
        "deduplicated_values": int(len(group) - len(ladder)),
        "r_min_kpc": float(radius.min()),
        "r_max_kpc": float(radius.max()),
        "minimum_value": float(ladder.min()),
        "maximum_value": float(ladder.max()),
        "profile_range": float(ladder.max() - ladder.min()),
        "profile_median_gap": float(np.median(gaps)),
        "profile_iqr": iqr(gaps),
        "profile_gap_variance": float(np.var(gaps, ddof=0)),
    }
    return ladder, stats


def clear_generated(directory: Path) -> None:
    directory.mkdir(parents=True, exist_ok=True)
    for path in directory.iterdir():
        if path.is_file() and path.suffix.lower() in {".csv", ".txt"}:
            path.unlink()


def write_lists(root: Path, manifest: pd.DataFrame) -> None:
    list_specs = {
        "PRIMARY_INPUT_LIST.txt": "is_primary",
        "INCLINATION_SENSITIVITY_INPUT_LIST.txt": "is_inclination_sensitivity",
        "RESOLUTION_SENSITIVITY_INPUT_LIST.txt": "is_resolution_sensitivity",
        "STRICT_SENSITIVITY_INPUT_LIST.txt": "is_strict_sensitivity",
        "BROAD_Q12_INPUT_LIST.txt": "is_broad_q12",
        "Q3_SENSITIVITY_INPUT_LIST.txt": "is_q3_sensitivity",
    }
    for filename, column in list_specs.items():
        ids = manifest.loc[(manifest["status"] == "READY") & manifest[column].fillna(False).astype(bool), "galaxy_id"].astype(str)
        (root / "ladders" / filename).write_text("\n".join(ids) + ("\n" if len(ids) else ""), encoding="utf-8")


def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[1]
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, default=root / "data/prepared_profiles/galaxy_profiles.csv")
    parser.add_argument("--project-root", type=Path, default=root)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    root = args.project_root.resolve()
    profiles = load_profiles(args.input)
    struc_i_dir = root / "ladders/chamber_inputs/struc_i"
    perc_dir = root / "ladders/chamber_inputs/struc_perc_i"
    clear_generated(struc_i_dir)
    clear_generated(perc_dir)

    rows = []
    for galaxy_id, group in profiles.groupby("galaxy_id", sort=True):
        galaxy_id = str(galaxy_id)
        safe_id = safe_identifier(galaxy_id)
        meta = group.iloc[0]
        try:
            ladder, stats = build_ladder(group)
            csv_path = struc_i_dir / f"{safe_id}.csv"
            txt_path = perc_dir / f"{safe_id}.txt"
            pd.DataFrame({"log10_g_bar": ladder}).to_csv(csv_path, index=False, float_format="%.17g")
            txt_path.write_text("\n".join(f"{value:.17g}" for value in ladder) + "\n", encoding="utf-8")
            rows.append({
                "galaxy_id": galaxy_id,
                "safe_id": safe_id,
                "quality_flag": int(meta["quality_flag"]),
                "inclination_deg": float(meta["inclination_deg"]),
                "is_primary": bool(meta["is_primary"]),
                "is_inclination_sensitivity": bool(meta["is_inclination_sensitivity"]),
                "is_resolution_sensitivity": bool(meta["is_resolution_sensitivity"]),
                "is_strict_sensitivity": bool(meta["is_strict_sensitivity"]),
                "is_broad_q12": bool(meta["is_broad_q12"]),
                "is_q3_sensitivity": bool(meta["is_q3_sensitivity"]),
                **stats,
                "values_sha256": values_hash(ladder),
                "struc_i_input": str(csv_path.relative_to(root)),
                "struc_i_file_sha256": sha256_file(csv_path),
                "struc_perc_i_input": str(txt_path.relative_to(root)),
                "struc_perc_i_file_sha256": sha256_file(txt_path),
                "interpolation_used": False,
                "status": "READY",
            })
        except Exception as exc:
            rows.append({
                "galaxy_id": galaxy_id,
                "safe_id": safe_id,
                "quality_flag": int(meta["quality_flag"]),
                "inclination_deg": float(meta["inclination_deg"]),
                "status": f"REJECTED: {exc}",
            })

    manifest = pd.DataFrame(rows)
    manifest.to_csv(root / "ladders/ladder_manifest.csv", index=False)
    write_lists(root, manifest)
    ready = int((manifest["status"] == "READY").sum())
    rejected = int(len(manifest) - ready)
    primary = int(manifest["is_primary"].fillna(False).astype(bool).sum())
    print(f"Wrote {ready} paired observed-sample ladders ({primary} PRIMARY); {rejected} rejected")
    return 0 if ready > 0 and rejected == 0 else 2


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