"""Build the figure used by docs/TESS-NOTE.md from committed outputs.

Run from the repository root:

    python research/tess-timing/scripts/make_note_figure.py

The script reads four files that are already in the repository:

- ``data/lightcurve-run/synthetic_lightcurve.csv``
- ``data/aperture-run/synthetic_tpf.csv``
- ``data/aperture-run/synthetic_tpf.json``
- ``data/synthetic_tpf.fits``

It writes ``docs/figures/tess-methods.svg``. No network request is made. The
inputs are synthetic fixtures, not observations, and the figure says so.

The figure has three panels: the quality-masked and median-normalized SPOC-shaped
light curve, the threshold-aperture series with its per-cadence uncertainty, and
the 21 by 21 median pixel image with the aperture and background annulus
overlaid. All axis limits, counts, and metrics are read from the committed files
or are simple arithmetic on their columns.
"""

from __future__ import annotations

import argparse
import csv
import json
import sys
from pathlib import Path

import numpy as np
from astropy.io import fits

PACKAGE_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = PACKAGE_ROOT.parents[1]

DEFAULT_LIGHTCURVE = PACKAGE_ROOT / "data" / "lightcurve-run" / "synthetic_lightcurve.csv"
DEFAULT_LIGHTCURVE_JSON = (
    PACKAGE_ROOT / "data" / "lightcurve-run" / "synthetic_lightcurve.json"
)
DEFAULT_APERTURE_CSV = PACKAGE_ROOT / "data" / "aperture-run" / "synthetic_tpf.csv"
DEFAULT_APERTURE_JSON = PACKAGE_ROOT / "data" / "aperture-run" / "synthetic_tpf.json"
DEFAULT_TPF = PACKAGE_ROOT / "data" / "synthetic_tpf.fits"
DEFAULT_OUT = REPO_ROOT / "docs" / "figures" / "tess-methods.svg"

SVG_NAMESPACE = "http://www.w3.org/2000/svg"  # sec-ok: http-url - XML namespace identifier, not a network request

INK = "#1b1b1b"
MUTED = "#67645c"
GRID = "#dedbd2"
AXIS = "#8c887e"
APERTURE = "#245c4a"
SAP = "#4a6fa5"
PDCSAP = "#a24b2a"
GAP = "#f0ece2"


def _escape(value: object) -> str:
    return (
        str(value)
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
    )


def _text(
    x: float,
    y: float,
    value: object,
    size: float = 12.0,
    fill: str = INK,
    anchor: str = "start",
    weight: str = "normal",
) -> str:
    return (
        f'<text x="{x:.1f}" y="{y:.1f}" font-family="Georgia, serif" '
        f'font-size="{size:.1f}" fill="{fill}" text-anchor="{anchor}" '
        f'font-weight="{weight}">{_escape(value)}</text>'
    )


def _line(
    x1: float,
    y1: float,
    x2: float,
    y2: float,
    stroke: str,
    width: float = 1.0,
    dash: str | None = None,
) -> str:
    extra = f' stroke-dasharray="{dash}"' if dash else ""
    return (
        f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
        f'stroke="{stroke}" stroke-width="{width:.2f}"{extra} />'
    )


def _rect(
    x: float,
    y: float,
    width: float,
    height: float,
    fill: str,
    stroke: str = "none",
    stroke_width: float = 0.0,
    opacity: float | None = None,
) -> str:
    extra = f' fill-opacity="{opacity:.2f}"' if opacity is not None else ""
    return (
        f'<rect x="{x:.1f}" y="{y:.1f}" width="{width:.1f}" '
        f'height="{height:.1f}" fill="{fill}" stroke="{stroke}" '
        f'stroke-width="{stroke_width:.2f}"{extra} />'
    )


def _circle(
    x: float,
    y: float,
    radius: float,
    fill: str,
    stroke: str = "none",
    stroke_width: float = 0.0,
) -> str:
    return (
        f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{radius:.1f}" fill="{fill}" '
        f'stroke="{stroke}" stroke-width="{stroke_width:.2f}" />'
    )


def _scale(value: float, domain: tuple[float, float], span: tuple[float, float]) -> float:
    low, high = domain
    return span[0] + (value - low) / (high - low) * (span[1] - span[0])


def _y_scale(value: float, domain: tuple[float, float], top: float, bottom: float) -> float:
    return bottom - (value - domain[0]) / (domain[1] - domain[0]) * (bottom - top)


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


def _float(row: dict[str, str], key: str) -> float:
    return float(row[key])


def _load_median_image(path: Path) -> np.ndarray:
    with fits.open(path) as hdul:
        data = hdul[1].data
        if data is None or "FLUX" not in data.names:
            raise ValueError(f"{path}: PIXELS extension has no FLUX column")
        flux = np.asarray(data["FLUX"], dtype=float)
    if flux.ndim != 3:
        raise ValueError(f"{path}: FLUX must be three-dimensional, got {flux.shape}")
    with np.errstate(invalid="ignore"):
        image = np.nanmedian(flux, axis=0)
    if not np.all(np.isfinite(image)):
        raise ValueError(f"{path}: median image contains non-finite pixels")
    return image


def _panel_axes(
    parts: list[str],
    x0: float,
    y0: float,
    width: float,
    height: float,
    x_domain: tuple[float, float],
    y_domain: tuple[float, float],
    x_ticks: list[float],
    y_ticks: list[float],
    x_label: str,
    y_label: str,
    x_format: str,
    y_format: str,
) -> tuple:
    def xmap(value: float) -> float:
        return _scale(value, x_domain, (x0, x0 + width))

    def ymap(value: float) -> float:
        return _y_scale(value, y_domain, y0, y0 + height)

    parts.append(_rect(x0, y0, width, height, "#ffffff", AXIS, 0.8))
    for value in y_ticks:
        y = ymap(value)
        parts.append(_line(x0, y, x0 + width, y, GRID, 0.6))
        parts.append(_text(x0 - 9, y + 4, y_format.format(value), 10.5, MUTED, "end"))
    for value in x_ticks:
        x = xmap(value)
        parts.append(_line(x, y0, x, y0 + height, GRID, 0.6))
        parts.append(_text(x, y0 + height + 17, x_format.format(value), 10.5, MUTED, "middle"))
    parts.append(_text(x0 + width / 2, y0 + height + 38, x_label, 11.5, MUTED, "middle"))
    parts.append(
        f'<text x="{x0 - 52:.1f}" y="{y0 + height / 2:.1f}" font-family="Georgia, serif" '
        f'font-size="11.5" fill="{MUTED}" text-anchor="middle" '
        f'transform="rotate(-90 {x0 - 52:.1f} {y0 + height / 2:.1f})">'
        f"{_escape(y_label)}</text>"
    )
    return xmap, ymap


def _light_curve_panel(
    parts: list[str],
    rows: list[dict[str, str]],
    lightcurve_json: dict[str, object],
    x0: float,
    y0: float,
    width: float,
    height: float,
) -> None:
    times = [_float(row, "time_btjd") for row in rows]
    sap = [_float(row, "sap_flux") for row in rows]
    pdcsap = [_float(row, "pdcsap_flux") for row in rows]
    start = min(times)
    x_values = [value - start for value in times]
    y_values = sap + pdcsap
    y_low = min(y_values) - 0.006
    y_high = max(y_values) + 0.006
    x_high = max(x_values) + 0.0008
    xmap, ymap = _panel_axes(
        parts,
        x0,
        y0,
        width,
        height,
        (0.0, x_high),
        (y_low, y_high),
        [0.0, 0.005, 0.010, 0.015, 0.020],
        [0.95, 1.00, 1.05],
        f"time since BTJD {start:.3f} (days)",
        "normalized flux",
        "{:.3f}",
        "{:.2f}",
    )

    gap_index = max(range(1, len(x_values)), key=lambda index: x_values[index] - x_values[index - 1])
    gap_start = xmap(x_values[gap_index - 1])
    gap_end = xmap(x_values[gap_index])
    parts.append(_rect(gap_start, y0, gap_end - gap_start, height, GAP, "none", 0.0, 0.9))
    parts.append(_text((gap_start + gap_end) / 2, y0 + 18, "gap", 10.5, MUTED, "middle"))

    points = [(xmap(x), ymap(y)) for x, y in zip(x_values, sap)]
    segments = [points[:gap_index], points[gap_index:]]
    for segment in segments:
        if len(segment) > 1:
            path = " ".join(f"{x:.1f},{y:.1f}" for x, y in segment)
            parts.append(
                f'<polyline points="{path}" fill="none" stroke="{SAP}" '
                'stroke-width="1.4" stroke-dasharray="4 3" />'
            )
    for x, y, p in zip(x_values, sap, pdcsap):
        px = xmap(x)
        parts.append(_circle(px, ymap(y), 3.6, SAP))
        parts.append(_circle(px, ymap(p), 4.8, "#ffffff", PDCSAP, 1.3))

    parts.append(_text(x0 + 12, y0 + 18, "Quality-masked light curve", 13, INK, weight="bold"))
    retained = int(lightcurve_json["cadence_counts"]["retained"])
    gap_count = int(lightcurve_json["gaps"]["gap_count"])
    missing = int(lightcurve_json["gaps"]["missing_cadences_in_gaps"])
    longest = float(lightcurve_json["gaps"]["longest_gap_seconds"])
    gap_label = "1 gap" if gap_count == 1 else f"{gap_count} gaps"
    parts.append(
        _text(
            x0 + 12,
            y0 + 34,
            f"{retained} retained cadences; SAP filled, PDCSAP open",
            10.5,
            MUTED,
        )
    )
    parts.append(
        _text(
            x0 + width - 12,
            y0 - 19,
            f"{gap_label}: {missing} missing cadences ({longest:.0f} s)",
            10.5,
            MUTED,
            "end",
        )
    )
    parts.append(_text(x0 + width - 12, y0 - 4, "no detrending applied", 10.5, MUTED, "end"))


def _aperture_panel(
    parts: list[str],
    rows: list[dict[str, str]],
    aperture_json: dict[str, object],
    x0: float,
    y0: float,
    width: float,
    height: float,
) -> None:
    times = [_float(row, "time_btjd") for row in rows]
    aperture = [_float(row, "aperture_flux") for row in rows]
    aperture_err = [_float(row, "aperture_flux_err") for row in rows]
    sap = [_float(row, "mission_sap_normalized") for row in rows]
    pdcsap = [_float(row, "mission_pdcsap_normalized") for row in rows]
    median = float(aperture_json["comparison"]["aperture_median_raw_flux"])
    normalized = [value / median for value in aperture]
    normalized_err = [value / median for value in aperture_err]
    start = min(times)
    x_values = [value - start for value in times]
    y_low = min(min(normalized), min(sap), min(pdcsap)) - 0.035
    y_high = max(max(normalized), max(sap), max(pdcsap)) + 0.035
    x_high = max(x_values) + 0.0008
    xmap, ymap = _panel_axes(
        parts,
        x0,
        y0,
        width,
        height,
        (0.0, x_high),
        (y_low, y_high),
        [0.0, 0.005, 0.010, 0.015],
        [0.96, 0.98, 1.00, 1.02, 1.04],
        f"time since BTJD {start:.3f} (days)",
        "normalized flux",
        "{:.3f}",
        "{:.2f}",
    )

    if len(x_values) > 1:
        sap_path = " ".join(
            f"{xmap(x):.1f},{ymap(y):.1f}" for x, y in zip(x_values, sap)
        )
        pdcsap_path = " ".join(
            f"{xmap(x):.1f},{ymap(y):.1f}" for x, y in zip(x_values, pdcsap)
        )
        parts.append(
            f'<polyline points="{sap_path}" fill="none" stroke="{SAP}" stroke-width="1.5" />'
        )
        parts.append(
            f'<polyline points="{pdcsap_path}" fill="none" stroke="{PDCSAP}" '
            'stroke-width="1.5" stroke-dasharray="5 3" />'
        )
    for x, y, error in zip(x_values, normalized, normalized_err):
        px = xmap(x)
        py = ymap(y)
        parts.append(_line(px, ymap(y - error), px, ymap(y + error), APERTURE, 1.0))
        parts.append(_line(px - 2.5, ymap(y - error), px + 2.5, ymap(y - error), APERTURE, 1.0))
        parts.append(_line(px - 2.5, ymap(y + error), px + 2.5, ymap(y + error), APERTURE, 1.0))
        parts.append(_circle(px, py, 3.2, APERTURE))

    comparison = aperture_json["comparison"]
    parts.append(_text(x0 + 12, y0 + 18, "Threshold aperture vs SPOC columns", 13, INK, weight="bold"))
    matched = int(aperture_json["comparison"]["matched_pairs"])
    parts.append(
        _text(
            x0 + 12,
            y0 + height - 26,
            f"{matched} common cadences; error bars are 1 sigma",
            10.5,
            MUTED,
        )
    )
    parts.append(
        _text(
            x0 + 12,
            y0 + height - 10,
            "aperture and mission series normalized by their medians",
            10.5,
            MUTED,
        )
    )
    parts.append(
        _text(
            x0 + width - 12,
            y0 + 18,
            f"RMS diff: SAP {comparison['sap']['rms_difference_ppm']:.1f} ppm",
            10.5,
            MUTED,
            "end",
        )
    )
    parts.append(
        _text(
            x0 + width - 12,
            y0 + 34,
            f"PDCSAP {comparison['pdcsap']['rms_difference_ppm']:.1f} ppm",
            10.5,
            MUTED,
            "end",
        )
    )


def _aperture_image_panel(
    parts: list[str],
    image: np.ndarray,
    aperture_json: dict[str, object],
    x0: float,
    y0: float,
    cell: float,
) -> None:
    rows, cols = image.shape
    low = float(np.min(image))
    high = float(np.max(image))
    parts.append(_text(x0, y0 - 18, "Median pixel image and aperture", 13, INK, weight="bold"))
    parts.append(
        _text(
            x0,
            y0 - 3,
            f"{cols} x {rows} pixels; darker means more flux",
            10.5,
            MUTED,
        )
    )
    for row in range(rows):
        for col in range(cols):
            value = (float(image[row, col]) - low) / (high - low) if high > low else 0.0
            shade = 255 - round(205.0 * (value**0.55))
            color = f"#{shade:02x}{shade:02x}{shade:02x}"
            parts.append(
                _rect(
                    x0 + col * cell,
                    y0 + row * cell,
                    cell + 0.15,
                    cell + 0.15,
                    color,
                    "#ffffff",
                    0.25,
                )
            )

    aperture = aperture_json["aperture"]
    mask = aperture["aperture_mask"]
    for row in range(rows):
        for col in range(cols):
            if int(mask[row][col]) == 1:
                parts.append(
                    _rect(
                        x0 + col * cell,
                        y0 + row * cell,
                        cell,
                        cell,
                        "none",
                        APERTURE,
                        1.8,
                    )
                )
    for row, col in zip(aperture["annulus_rows"], aperture["annulus_cols"]):
        parts.append(
            _rect(
                x0 + int(col) * cell,
                y0 + int(row) * cell,
                cell,
                cell,
                "none",
                PDCSAP,
                0.8,
            )
        )

    parts.append(
        _rect(x0, y0, cols * cell, rows * cell, "none", AXIS, 0.9)
    )
    legend_x = x0 + cols * cell + 16
    aperture_pixels = int(aperture["aperture_pixel_count"])
    annulus_pixels = int(aperture["annulus_pixel_count"])
    threshold = float(aperture_json["config"]["threshold_fraction"])
    inner = float(aperture_json["config"]["inner_radius_pixels"])
    outer = float(aperture_json["config"]["outer_radius_pixels"])
    parts.append(_rect(legend_x, y0 + 6, 14, 14, "none", APERTURE, 2.0))
    parts.append(_text(legend_x + 20, y0 + 17, f"aperture: {aperture_pixels} px", 10.5, INK))
    parts.append(_text(legend_x + 20, y0 + 32, f"threshold {threshold * 100:.0f}% of peak", 10, MUTED))
    parts.append(_rect(legend_x, y0 + 52, 14, 14, "none", PDCSAP, 0.9))
    parts.append(_text(legend_x + 20, y0 + 63, f"annulus: {annulus_pixels} px", 10.5, INK))
    parts.append(_text(legend_x + 20, y0 + 78, f"{inner:.1f}-{outer:.1f} pixels", 10, MUTED))
    parts.append(_text(legend_x + 20, y0 + 110, "centroid", 10, MUTED))
    parts.append(_text(legend_x + 20, y0 + 124, "row 10.0, col 10.0", 10, INK))


def build_figure(
    lightcurve_path: Path,
    lightcurve_json_path: Path,
    aperture_csv_path: Path,
    aperture_json_path: Path,
    tpf_path: Path,
) -> str:
    lightcurve_rows = _read_csv(lightcurve_path)
    lightcurve_json = json.loads(lightcurve_json_path.read_text(encoding="utf-8"))
    aperture_rows = _read_csv(aperture_csv_path)
    aperture_json = json.loads(aperture_json_path.read_text(encoding="utf-8"))
    image = _load_median_image(tpf_path)

    width = 1080
    height = 820
    parts = [
        (
            f'<svg xmlns="{SVG_NAMESPACE}" width="{width}" height="{height}" '
            f'viewBox="0 0 {width} {height}" role="img" '
            'aria-labelledby="tess-title tess-desc">'
        ),
        '<title id="tess-title">TESS light-curve and aperture method check</title>',
        (
            '<desc id="tess-desc">Three panels built from synthetic fixtures: the '
            "quality-masked and median-normalized light curve, the threshold "
            "aperture flux with one-sigma errors against the mission SAP and "
            "PDCSAP columns, and the median pixel image with the aperture and "
            "annulus overlaid. This is not an observation.</desc>"
        ),
        _rect(0, 0, width, height, "#ffffff"),
        _text(90, 34, "TESS methods check: light curve and aperture", 22, INK, weight="bold"),
        _text(
            90,
            58,
            "Synthetic fixtures, not an observation. No detection, transit fit, or timing measurement.",
            12.5,
            MUTED,
        ),
        _text(
            90,
            80,
            "The top panel and the lower-left panel are built from the committed CSV files; "
            "the lower-right panel reads the committed target pixel file.",
            11.5,
            MUTED,
        ),
    ]

    _light_curve_panel(parts, lightcurve_rows, lightcurve_json, 90.0, 134.0, 954.0, 226.0)
    _aperture_panel(parts, aperture_rows, aperture_json, 90.0, 432.0, 470.0, 282.0)
    _aperture_image_panel(parts, image, aperture_json, 640.0, 440.0, 10.5)

    parts.append(_line(90, 760, 1044, 760, AXIS, 0.8))
    parts.append(
        _text(
            90,
            780,
            "Sources: research/tess-timing/data/lightcurve-run/synthetic_lightcurve.csv; "
            "research/tess-timing/data/aperture-run/synthetic_tpf.csv and .json; "
            "research/tess-timing/data/synthetic_tpf.fits.",
            10.2,
            MUTED,
        )
    )
    parts.append(
        _text(
            90,
            796,
            "Generated by research/tess-timing/scripts/make_note_figure.py. "
            "Counts and metrics are read from the committed JSON records.",
            10.2,
            MUTED,
        )
    )
    parts.append("</svg>")
    return "\n".join(parts) + "\n"


def _display(path: Path) -> str:
    try:
        return path.resolve().relative_to(REPO_ROOT.resolve()).as_posix()
    except ValueError:
        return str(path)


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Build the docs/TESS-NOTE.md figure from committed synthetic outputs. "
            "No network request is made."
        )
    )
    parser.add_argument("--lightcurve", type=Path, default=DEFAULT_LIGHTCURVE)
    parser.add_argument("--lightcurve-json", type=Path, default=DEFAULT_LIGHTCURVE_JSON)
    parser.add_argument("--aperture-csv", type=Path, default=DEFAULT_APERTURE_CSV)
    parser.add_argument("--aperture-json", type=Path, default=DEFAULT_APERTURE_JSON)
    parser.add_argument("--tpf", type=Path, default=DEFAULT_TPF)
    parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
    return parser


def main(argv: list[str] | None = None) -> int:
    arguments = _parser().parse_args(argv)
    try:
        svg = build_figure(
            arguments.lightcurve,
            arguments.lightcurve_json,
            arguments.aperture_csv,
            arguments.aperture_json,
            arguments.tpf,
        )
    except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
        print(f"error: {error}", file=sys.stderr)
        return 1
    arguments.out.parent.mkdir(parents=True, exist_ok=True)
    arguments.out.write_text(svg, encoding="utf-8")
    print(
        f"status=OK synthetic=yes out={_display(arguments.out)} "
        f"bytes={len(svg.encode('utf-8'))}"
    )
    return 0


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