"""Held-out evaluation exercise: stare at one static point and compare filters.

Unlike gaze_exercises.py (used to collect training data), this task never
appears in training: a single unmoving target held for a long duration. It
measures whether the trained classifier's behavior generalizes to a task
shape it has never seen, which is the actual claim an adaptive filter needs
to support. Run after train_filter_model.py has produced models/filter_model.joblib.
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import queue
import statistics
import threading
import time
import tkinter as tk
from datetime import datetime
from pathlib import Path

from show_apriltag_surface import SurfaceTagWindow, enable_windows_dpi_awareness, validate_args
from visualize_surface_gaze import GazeSample, SurfaceReceiver
from adaptive_filter import BaselineFilter, MLAdaptiveFilter

SCREEN_WIDTH = 2560
SCREEN_HEIGHT = 1440
SCREEN_DIAGONAL_IN = 31.5
DEFAULT_VIEWING_DISTANCE_CM = 60.0
TARGET_RADIUS = 40

METHODS = ("raw", "baseline", "ml_hard", "ml_soft")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Static-fixation held-out evaluation: raw vs. fixed vs. ML-adaptive filters."
    )
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=50020)
    parser.add_argument("--surface", default="screen")
    parser.add_argument("--confidence", type=float, default=0.9)
    parser.add_argument("--screen-diagonal", type=float, default=SCREEN_DIAGONAL_IN)
    parser.add_argument("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM)
    parser.add_argument("--subject-id", default="anon")
    parser.add_argument("--session-tag", default="")
    parser.add_argument("--duration", type=float, default=60.0,
                        help="seconds to hold the static target (default: 60)")
    parser.add_argument("--accuracy-radius", type=float, default=60.0,
                        help="pixel radius counted as 'on target' for accuracy % (default: 60)")
    parser.add_argument("--model", default="models/filter_model.joblib",
                        help="path to trained classifier; omit ML columns if not found")
    parser.add_argument("--show", choices=METHODS, default="ml_soft",
                        help="which filtered stream to display as the live cursor (default: ml_soft)")
    parser.add_argument("--cursor", action="store_true", help="show the live cursor (hidden by default)")
    parser.add_argument("--tag-ids", nargs=4, type=int, default=(0, 1, 2, 3))
    parser.add_argument("--tag-size", type=int, default=200)
    parser.add_argument("--margin", type=int, default=0)
    parser.add_argument("--background", default="#10141c")
    parser.add_argument("--windowed", action="store_true")
    return parser.parse_args()


class StaticFixationWindow(SurfaceTagWindow):
    def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
        super().__init__(root, args)
        self.args = args
        self.messages: queue.Queue = queue.Queue(maxsize=256)
        self.stop_event = threading.Event()
        self.receiver = SurfaceReceiver(
            self.messages, self.stop_event, args.host, args.port, args.surface, args.confidence
        )
        self.receiver.start()
        self.target = (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)

        diagonal_px = math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT)
        self.cm_per_px = args.screen_diagonal * 2.54 / diagonal_px

        self.filters = {"raw": None, "baseline": BaselineFilter()}
        self.model_path = Path(args.model)
        if self.model_path.exists():
            self.filters["ml_hard"] = MLAdaptiveFilter(
                self.model_path, self.cm_per_px, args.viewing_distance, mode="hard"
            )
            self.filters["ml_soft"] = MLAdaptiveFilter(
                self.model_path, self.cm_per_px, args.viewing_distance, mode="soft"
            )
        else:
            print(f"warning: model not found at {self.model_path}; "
                  "recording raw + baseline only (train_filter_model.py first for ML columns)")

        self.errors: dict[str, list[float]] = {m: [] for m in METHODS if self._active(m)}
        self.started_at: float | None = None

        results_dir = Path(__file__).resolve().parent / "results"
        results_dir.mkdir(exist_ok=True)
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        self.result_path = results_dir / f"eval_static_fixation_{stamp}.csv"
        self.result_file = self.result_path.open("w", newline="", encoding="utf-8")
        self.writer = csv.writer(self.result_file)
        header = ["subject_id", "pupil_timestamp", "elapsed_s", "target_x", "target_y", "confidence"]
        for method in METHODS:
            if self._active(method):
                header += [f"{method}_x", f"{method}_y", f"{method}_error_px"]
        self.writer.writerow(header)

        self.target_item = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
        self.gaze_item = self.canvas.create_oval(
            0, 0, 0, 0, fill="#ffd43b", outline="white", width=2, state="hidden", tags="exercise"
        )
        self.heading = self.canvas.create_text(
            0, 70, fill="white", font=("Segoe UI", 25, "bold"), tags="exercise"
        )
        self.instructions = self.canvas.create_text(
            0, 125, fill="#d7deea", font=("Segoe UI", 16), justify="center", tags="exercise"
        )
        root.bind("<space>", self.on_space)
        root.protocol("WM_DELETE_WINDOW", self.close)
        self.show_welcome()
        root.after(8, self.tick)

    def _active(self, method: str) -> bool:
        return method == "raw" or method in self.filters

    def sx(self, x: float) -> float:
        return x / SCREEN_WIDTH * self.canvas.winfo_width()

    def sy(self, y: float) -> float:
        return y / SCREEN_HEIGHT * self.canvas.winfo_height()

    def draw(self, event: tk.Event) -> None:
        super().draw(event)
        self.canvas.coords(self.heading, event.width / 2, 65)
        self.canvas.coords(self.instructions, event.width / 2, 115)
        self.position_target()
        self.canvas.tag_raise("exercise")

    def position_target(self) -> None:
        x, y = self.sx(self.target[0]), self.sy(self.target[1])
        rx = TARGET_RADIUS / SCREEN_WIDTH * self.canvas.winfo_width()
        ry = TARGET_RADIUS / SCREEN_HEIGHT * self.canvas.winfo_height()
        self.canvas.coords(self.target_item, x - rx, y - ry, x + rx, y + ry)
        self.canvas.itemconfigure(self.target_item, fill="#55aaff", outline="white", width=3, state="normal")

    def show_welcome(self) -> None:
        self.canvas.itemconfigure(self.heading, text="Static fixation evaluation")
        self.canvas.itemconfigure(
            self.instructions,
            text=(f"Stare at the single dot for {self.args.duration:.0f} seconds.\n"
                  "Sit comfortably, keep your head natural, then press SPACE."),
        )

    def on_space(self, _event: tk.Event | None = None) -> None:
        if self.started_at is None:
            self.started_at = time.monotonic()
            self.position_target()
            self.set_text("Hold your gaze on the dot", "")

    def set_text(self, heading: str, instructions: str) -> None:
        self.canvas.itemconfigure(self.heading, text=heading)
        self.canvas.itemconfigure(self.instructions, text=instructions)

    def close(self, _event: tk.Event | None = None) -> None:
        self.stop_event.set()
        if not self.result_file.closed:
            self.result_file.close()
        super().close(_event)

    def record_sample(self, sample: GazeSample, gaze: tuple[float, float], elapsed: float) -> None:
        row = [self.args.subject_id, f"{sample.timestamp:.6f}", f"{elapsed:.3f}",
               f"{self.target[0]:.1f}", f"{self.target[1]:.1f}", f"{sample.confidence:.3f}"]
        display_pos = gaze
        for method in METHODS:
            if not self._active(method):
                continue
            if method == "raw":
                pos = gaze
            else:
                pos = self.filters[method].update(sample.timestamp, gaze[0], gaze[1], sample.confidence)
            error = math.dist(pos, self.target)
            self.errors[method].append(error)
            row += [f"{pos[0]:.1f}", f"{pos[1]:.1f}", f"{error:.1f}"]
            if method == self.args.show:
                display_pos = pos
        self.writer.writerow(row)

        self.position_circle(self.gaze_item, display_pos, 14)
        if self.args.cursor:
            self.canvas.itemconfigure(self.gaze_item, state="normal")

    def position_circle(self, item: int, point: tuple[float, float], radius: int) -> None:
        x, y = self.sx(point[0]), self.sy(point[1])
        rx = radius / SCREEN_WIDTH * self.canvas.winfo_width()
        ry = radius / SCREEN_HEIGHT * self.canvas.winfo_height()
        self.canvas.coords(item, x - rx, y - ry, x + rx, y + ry)

    def tick(self) -> None:
        now = time.monotonic()
        try:
            while True:
                kind, value = self.messages.get_nowait()
                if kind == "gaze" and self.started_at is not None:
                    sample: GazeSample = value  # type: ignore[assignment]
                    gaze = (sample.x_norm * SCREEN_WIDTH, (1.0 - sample.y_norm) * SCREEN_HEIGHT)
                    self.record_sample(sample, gaze, now - self.started_at)
        except queue.Empty:
            pass

        if self.started_at is not None and now - self.started_at >= self.args.duration:
            self.finish()
            return

        if self.root.winfo_exists():
            self.root.after(8, self.tick)

    def finish(self) -> None:
        self.result_file.flush()
        self.result_file.close()

        report = {}
        lines = []
        for method in METHODS:
            if not self._active(method):
                continue
            errs = self.errors[method]
            if not errs:
                continue
            rms = math.sqrt(sum(e * e for e in errs) / len(errs))
            mean = statistics.fmean(errs)
            on_target = sum(1 for e in errs if e <= self.args.accuracy_radius) / len(errs) * 100
            report[method] = {"rms_px": rms, "mean_px": mean, "accuracy_pct": on_target, "n": len(errs)}
            lines.append(f"{method:<9} rms {rms:6.1f}px  mean {mean:6.1f}px  "
                          f"accuracy {on_target:5.1f}% (<= {self.args.accuracy_radius:.0f}px)")

        meta_path = self.result_path.with_name(self.result_path.stem + "_meta.json")
        meta_path.write_text(json.dumps({
            "subject_id": self.args.subject_id,
            "session_tag": self.args.session_tag,
            "recorded_at": datetime.now().isoformat(timespec="seconds"),
            "duration_s": self.args.duration,
            "screen_diagonal_in": self.args.screen_diagonal,
            "viewing_distance_cm": self.args.viewing_distance,
            "accuracy_radius_px": self.args.accuracy_radius,
            "model": str(self.model_path) if self.model_path.exists() else None,
            "result_csv": self.result_path.name,
            "report": report,
        }, indent=2), encoding="utf-8")

        summary = "\n".join(lines)
        print(summary)
        self.set_text("Done", summary + f"\n\nSaved to:\n{self.result_path}\n\nPress Esc to close")
        self.canvas.itemconfigure(self.target_item, state="hidden")
        self.stop_event.set()


def main() -> None:
    args = parse_args()
    validate_args(args)
    if not 0 <= args.confidence <= 1:
        raise SystemExit("error: --confidence must be between 0 and 1")
    if args.screen_diagonal <= 0:
        raise SystemExit("error: --screen-diagonal must be greater than 0")
    if args.viewing_distance <= 0:
        raise SystemExit("error: --viewing-distance must be greater than 0")
    if args.duration <= 0:
        raise SystemExit("error: --duration must be greater than 0")
    enable_windows_dpi_awareness()
    root = tk.Tk()
    StaticFixationWindow(root, args)
    root.mainloop()


if __name__ == "__main__":
    main()
