"""Visualize CSV output produced by gaze_exercises.py."""

from __future__ import annotations

import argparse
import csv
from dataclasses import dataclass
import math
from pathlib import Path
import statistics

try:
    import matplotlib.pyplot as plt
except ImportError as exc:
    raise SystemExit(
        "Missing dependency. Run: python -m pip install -r requirements.txt"
    ) from exc


SCREEN_WIDTH = 2560
SCREEN_HEIGHT = 1440
SCREEN_DIAGONAL_IN = 31.5
DEFAULT_VIEWING_DISTANCE_CM = 70.0
BLOCKS = ("fixation", "saccade", "colors", "pursuit")
TITLES = {
    "fixation": "Steady fixation",
    "saccade": "Quick target acquisition",
    "colors": "Green target selection",
    "pursuit": "Smooth pursuit",
}


@dataclass(frozen=True)
class Row:
    timestamp: float
    round: int
    block: str
    trial: int
    elapsed: float
    target_x: float
    target_y: float
    gaze_x: float
    gaze_y: float
    error: float
    hit: bool


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Plot gaze exercise results.")
    parser.add_argument(
        "csv_path",
        nargs="?",
        type=Path,
        help="result CSV; defaults to the newest file in results/",
    )
    parser.add_argument("--save", type=Path, help="output PNG path")
    parser.add_argument("--no-show", action="store_true", help="save without opening a window")
    parser.add_argument("--screen-diagonal", type=float, default=SCREEN_DIAGONAL_IN,
                        help="physical screen diagonal in inches (default: 31.5)")
    parser.add_argument("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM,
                        help="eye-to-screen distance in cm (default: 60)")
    return parser.parse_args()


def newest_result() -> Path:
    results = Path(__file__).resolve().parent / "results"
    files = list(results.glob("gaze_exercises_*.csv"))
    if not files:
        raise SystemExit("No gaze exercise CSV files found in results/")
    return max(files, key=lambda path: path.stat().st_mtime)


def load_rows(path: Path) -> list[Row]:
    try:
        with path.open(newline="", encoding="utf-8-sig") as file:
            rows = [
                Row(
                    float(item["pupil_timestamp"]), int(item.get("round") or 1),
                    item["block"], int(item["trial"]),
                    float(item["trial_elapsed_s"]), float(item["target_x"]),
                    float(item["target_y"]), float(item["gaze_x"]),
                    float(item["gaze_y"]), float(item["error_px"]),
                    item["hit"].strip().lower() in {"1", "true", "yes"},
                )
                for item in csv.DictReader(file)
            ]
    except (OSError, KeyError, TypeError, ValueError) as exc:
        raise SystemExit(f"Could not read {path}: {exc}") from exc
    if not rows:
        raise SystemExit(f"No data rows found in {path}")
    return rows


def percentile(values: list[float], fraction: float) -> float:
    ordered = sorted(values)
    return ordered[round((len(ordered) - 1) * fraction)]


def setup_screen_axis(axis, title: str) -> None:
    axis.set_title(title, fontweight="bold")
    axis.set_xlim(0, SCREEN_WIDTH)
    axis.set_ylim(SCREEN_HEIGHT, 0)
    axis.set_aspect("equal", adjustable="box")
    axis.set_facecolor("#111722")
    axis.grid(color="white", alpha=0.08, linewidth=0.6)
    axis.set_xlabel("screen x (px)")
    axis.set_ylabel("screen y (px)")


def plot_screen_map(axis, block: str, rows: list[Row]) -> None:
    setup_screen_axis(axis, TITLES[block])
    if not rows:
        axis.text(0.5, 0.5, "No data", transform=axis.transAxes, ha="center")
        return

    # Limit rendering cost without changing the statistics.
    stride = max(1, len(rows) // 1800)
    shown = rows[::stride]
    target_color = "#40e883" if block == "colors" else "#58a6ff"
    if block == "pursuit":
        axis.plot(
            [row.target_x for row in shown], [row.target_y for row in shown],
            color=target_color, linewidth=2.2, label="target path"
        )
        axis.plot(
            [row.gaze_x for row in shown], [row.gaze_y for row in shown],
            color="#ffd43b", linewidth=1.0, alpha=0.65, label="gaze path"
        )
    else:
        targets: dict[tuple[int, int], tuple[float, float]] = {}
        for row in rows:
            targets.setdefault((row.round, row.trial), (row.target_x, row.target_y))
        axis.scatter(
            [point[0] for point in targets.values()],
            [point[1] for point in targets.values()],
            s=95, color=target_color, edgecolor="white", linewidth=1.2,
            zorder=3, label="target"
        )
        axis.scatter(
            [row.gaze_x for row in shown], [row.gaze_y for row in shown],
            s=8, color="#ffd43b", alpha=0.28, edgecolors="none", label="gaze"
        )
    axis.legend(loc="lower right", fontsize=8)


def error_cm(error_px: float, diagonal_in: float) -> float:
    return error_px * diagonal_in * 2.54 / math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT)


def error_deg(error_px: float, diagonal_in: float, viewing_distance_cm: float) -> float:
    cm = error_cm(error_px, diagonal_in)
    return math.degrees(2.0 * math.atan2(cm / 2.0, viewing_distance_cm))


def make_dashboard(path: Path, rows: list[Row], diagonal_in: float, viewing_distance_cm: float):
    grouped = {block: [row for row in rows if row.block == block] for block in BLOCKS}
    figure, axes = plt.subplots(3, 2, figsize=(15, 13), constrained_layout=True)
    figure.suptitle(f"Gaze exercise results — {path.name}", fontsize=17, fontweight="bold")

    for axis, block in zip(axes.flat[:4], BLOCKS):
        plot_screen_map(axis, block, grouped[block])

    error_axis = axes[2, 0]
    available = [block for block in BLOCKS if grouped[block]]
    error_sets = [[error_cm(row.error, diagonal_in) for row in grouped[block]] for block in available]
    boxes = error_axis.boxplot(error_sets, tick_labels=[TITLES[b] for b in available], patch_artist=True, showfliers=False)
    for box, color in zip(boxes["boxes"], ("#58a6ff", "#ff9f43", "#40e883", "#b084f5")):
        box.set_facecolor(color)
        box.set_alpha(0.75)
    hit_cm = error_cm(140, diagonal_in)
    error_axis.axhline(hit_cm, color="#d62728", linestyle="--", linewidth=1.3,
                       label=f"hit radius: 140 px / {hit_cm:.2f} cm")
    error_axis.set_title("Physical-error distribution", fontweight="bold")
    error_axis.set_ylabel("distance from target (cm)")
    error_axis.tick_params(axis="x", rotation=15)
    error_axis.grid(axis="y", alpha=0.25)
    error_axis.legend(fontsize=8)

    metric_axis = axes[2, 1]
    means = [statistics.fmean(row.error for row in grouped[b]) for b in available]
    hit_rates = [100 * statistics.fmean(row.hit for row in grouped[b]) for b in available]
    positions = list(range(len(available)))
    bars = metric_axis.bar(positions, hit_rates, color=("#58a6ff", "#ff9f43", "#40e883", "#b084f5"))
    metric_axis.set_ylim(0, 105)
    metric_axis.set_ylabel("on-target samples (%)")
    metric_axis.set_xticks(positions, [TITLES[b] for b in available], rotation=15)
    metric_axis.set_title("Accuracy summary", fontweight="bold")
    metric_axis.grid(axis="y", alpha=0.25)
    for bar, rate, mean in zip(bars, hit_rates, means):
        metric_axis.text(
            bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,
            (f"{rate:.0f}%\n{mean:.0f}px / {error_cm(mean, diagonal_in):.2f}cm\n"
             f"{error_deg(mean, diagonal_in, viewing_distance_cm):.2f}deg"),
            ha="center", va="bottom", fontsize=9
        )
    return figure, grouped


def print_summary(path: Path, grouped: dict[str, list[Row]], diagonal_in: float,
                  viewing_distance_cm: float) -> None:
    print(f"Results: {path}")
    print(f"Monitor: {SCREEN_WIDTH}x{SCREEN_HEIGHT}, {diagonal_in:g} in; viewing distance: {viewing_distance_cm:g} cm")
    print(f"{'block':<12} {'samples':>8} {'mean px':>9} {'mean cm':>9} {'mean deg':>9} {'hit %':>8}")
    for block in BLOCKS:
        rows = grouped[block]
        if not rows:
            continue
        errors = [row.error for row in rows]
        hit_rate = 100 * statistics.fmean(row.hit for row in rows)
        print(
            f"{block:<12} {len(rows):>8} {statistics.fmean(errors):>9.1f} "
            f"{error_cm(statistics.fmean(errors), diagonal_in):>9.3f} "
            f"{error_deg(statistics.fmean(errors), diagonal_in, viewing_distance_cm):>9.3f} "
            f"{hit_rate:>7.1f}%"
        )


def main() -> None:
    args = parse_args()
    if args.screen_diagonal <= 0 or args.viewing_distance <= 0:
        raise SystemExit("error: screen diagonal and viewing distance must be greater than 0")
    path = (args.csv_path or newest_result()).resolve()
    rows = load_rows(path)
    figure, grouped = make_dashboard(path, rows, args.screen_diagonal, args.viewing_distance)
    output = (args.save or path.with_name(f"{path.stem}_dashboard.png")).resolve()
    figure.savefig(output, dpi=160, facecolor="white")
    print_summary(path, grouped, args.screen_diagonal, args.viewing_distance)
    print(f"Dashboard saved: {output}")
    if not args.no_show:
        plt.show()


if __name__ == "__main__":
    main()
