#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import math
import shutil
import struct
import subprocess
import sys
import threading
import time
import wave
import webbrowser
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from queue import Empty, Queue
from typing import Any

import requests
import tkinter as tk
from tkinter import ttk


EARTH_RADIUS_KM = 6371.0
DEFAULT_ISS_ALTITUDE_KM = 420.0
DEFAULT_ISS_SPEED_KMH = 27600.0
DEFAULT_INTERVAL_SECONDS = 20
FREQUENCY_MHZ = 145.800
MODE = "FM"
REQUEST_TIMEOUT = 10
PROJECT_DIR = Path(__file__).resolve().parent
STATIONS_FILE = PROJECT_DIR / "stations.json"
ALARM_FILE = PROJECT_DIR / "alarm.wav"
API_URLS = (
    "https://api.wheretheiss.at/v1/satellites/25544",
    "http://api.open-notify.org/iss-now.json",
)


@dataclass(frozen=True)
class Station:
    name: str
    location: str
    latitude: float
    longitude: float
    url: str
    notes: str = ""


@dataclass(frozen=True)
class IssSnapshot:
    latitude: float
    longitude: float
    altitude_km: float
    speed_kmh: float
    coverage_radius_km: float
    timestamp: int
    provider: str
    raw_payload: dict[str, Any]


@dataclass(frozen=True)
class StationObservation:
    station: Station
    distance_km: float
    margin_km: float
    in_range: bool


def load_stations(path: Path) -> list[Station]:
    raw = json.loads(path.read_text(encoding="utf-8"))
    stations: list[Station] = []
    for item in raw:
        stations.append(
            Station(
                name=item["name"],
                location=item["location"],
                latitude=float(item["latitude"]),
                longitude=float(item["longitude"]),
                url=item["url"],
                notes=item.get("notes", ""),
            )
        )
    return stations


def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
    lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
    delta_lat = lat2_rad - lat1_rad
    delta_lon = lon2_rad - lon1_rad
    a = (
        math.sin(delta_lat / 2) ** 2
        + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2
    )
    return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))


def radio_horizon_km(altitude_km: float) -> float:
    altitude_km = max(0.0, altitude_km)
    central_angle = math.acos(EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitude_km))
    return EARTH_RADIUS_KM * central_angle


def fetch_iss_snapshot(session: requests.Session) -> IssSnapshot:
    errors: list[str] = []
    for url in API_URLS:
        try:
            response = session.get(url, timeout=REQUEST_TIMEOUT)
            response.raise_for_status()
            data = response.json()
            if "wheretheiss" in url:
                altitude = float(data.get("altitude", DEFAULT_ISS_ALTITUDE_KM))
                timestamp = int(data.get("timestamp", time.time()))
                speed = float(data.get("velocity", DEFAULT_ISS_SPEED_KMH))
                return IssSnapshot(
                    latitude=float(data["latitude"]),
                    longitude=float(data["longitude"]),
                    altitude_km=altitude,
                    speed_kmh=speed,
                    coverage_radius_km=radio_horizon_km(altitude),
                    timestamp=timestamp,
                    provider="wheretheiss.at",
                    raw_payload=data,
                )
            if "open-notify" in url:
                iss_position = data["iss_position"]
                altitude = DEFAULT_ISS_ALTITUDE_KM
                timestamp = int(data.get("timestamp", time.time()))
                return IssSnapshot(
                    latitude=float(iss_position["latitude"]),
                    longitude=float(iss_position["longitude"]),
                    altitude_km=altitude,
                    speed_kmh=DEFAULT_ISS_SPEED_KMH,
                    coverage_radius_km=radio_horizon_km(altitude),
                    timestamp=timestamp,
                    provider="open-notify.org",
                    raw_payload=data,
                )
        except Exception as exc:  # pragma: no cover - network paths vary
            errors.append(f"{url}: {exc}")
    raise RuntimeError("Nu am putut citi pozitia ISS.\n" + "\n".join(errors))


def evaluate_stations(
    snapshot: IssSnapshot, stations: list[Station]
) -> list[StationObservation]:
    observations: list[StationObservation] = []
    for station in stations:
        distance = haversine_km(
            snapshot.latitude,
            snapshot.longitude,
            station.latitude,
            station.longitude,
        )
        margin = snapshot.coverage_radius_km - distance
        observations.append(
            StationObservation(
                station=station,
                distance_km=distance,
                margin_km=margin,
                in_range=margin >= 0,
            )
        )
    observations.sort(key=lambda item: (not item.in_range, item.distance_km))
    return observations


def build_alarm_wav(path: Path) -> None:
    sample_rate = 44100
    segments = (
        (880.0, 0.16),
        (0.0, 0.05),
        (990.0, 0.16),
        (0.0, 0.05),
        (1320.0, 0.22),
    )
    amplitude = 14000
    with wave.open(str(path), "w") as wav_file:
        wav_file.setnchannels(1)
        wav_file.setsampwidth(2)
        wav_file.setframerate(sample_rate)
        for frequency, duration in segments:
            frame_count = int(sample_rate * duration)
            for index in range(frame_count):
                if frequency == 0.0:
                    sample = 0
                else:
                    sample = int(
                        amplitude
                        * math.sin(2 * math.pi * frequency * (index / sample_rate))
                    )
                wav_file.writeframesraw(struct.pack("<h", sample))


def ensure_alarm_file(path: Path) -> Path:
    if not path.exists():
        build_alarm_wav(path)
    return path


def play_alarm_sound(path: Path) -> bool:
    for command in ("paplay", "aplay", "play"):
        if not shutil.which(command):
            continue
        if command == "play":
            args = [command, "-q", str(path)]
        else:
            args = [command, str(path)]
        try:
            subprocess.Popen(
                args,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            return True
        except OSError:
            continue
    return False


def send_desktop_notification(title: str, message: str) -> bool:
    if not shutil.which("notify-send"):
        return False
    try:
        subprocess.Popen(
            ["notify-send", "-u", "normal", title, message],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        return True
    except OSError:
        return False


def format_coords(latitude: float, longitude: float) -> str:
    lat_suffix = "N" if latitude >= 0 else "S"
    lon_suffix = "E" if longitude >= 0 else "W"
    return f"{abs(latitude):.2f} {lat_suffix}, {abs(longitude):.2f} {lon_suffix}"


def format_local_time(timestamp: int) -> str:
    return datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone().strftime(
        "%d.%m.%Y %H:%M:%S"
    )


def summarize_snapshot(snapshot: IssSnapshot, observations: list[StationObservation]) -> str:
    active = [item for item in observations if item.in_range]
    nearest = observations[0] if observations else None
    lines = [
        f"ISS: {format_coords(snapshot.latitude, snapshot.longitude)}",
        f"Altitudine: {snapshot.altitude_km:.1f} km",
        f"Raza radio estimata: {snapshot.coverage_radius_km:.0f} km",
        f"Sursa: {snapshot.provider}",
        f"Actualizat: {format_local_time(snapshot.timestamp)}",
    ]
    if active:
        best = active[0]
        lines.append(
            f"Recomandat: {best.station.name} ({best.distance_km:.0f} km) -> {best.station.url}"
        )
    elif nearest:
        lines.append(
            f"Cea mai apropiata statie: {nearest.station.name} ({nearest.distance_km:.0f} km)"
        )
    return "\n".join(lines)


class IssRadioMonitorApp:
    def __init__(self, root: tk.Tk, stations: list[Station]) -> None:
        self.root = root
        self.stations = stations
        self.session = requests.Session()
        self.session.headers.update(
            {"User-Agent": "ISS-Radio-Monitor/1.0 (+tkinter; requests)"}
        )
        self.queue: Queue[tuple[str, Any]] = Queue()
        self.monitoring = False
        self.fetch_in_progress = False
        self.after_id: str | None = None
        self.current_in_range: set[str] = set()
        self.last_snapshot: IssSnapshot | None = None
        self.last_observations: list[StationObservation] = []
        self.alarm_file = ensure_alarm_file(ALARM_FILE)

        self.interval_var = tk.IntVar(value=DEFAULT_INTERVAL_SECONDS)
        self.auto_open_var = tk.BooleanVar(value=False)
        self.monitor_state_var = tk.StringVar(value="Oprit")
        self.iss_position_var = tk.StringVar(value="Necunoscuta")
        self.iss_altitude_var = tk.StringVar(value="-")
        self.iss_speed_var = tk.StringVar(value="-")
        self.iss_coverage_var = tk.StringVar(value="-")
        self.provider_var = tk.StringVar(value="-")
        self.updated_var = tk.StringVar(value="-")
        self.recommendation_var = tk.StringVar(
            value="Nu exista inca o statie recomandata."
        )
        self.recommendation_url_var = tk.StringVar(value="")
        self.status_var = tk.StringVar(
            value="Apasa 'Porneste monitorizarea' pentru a incepe."
        )

        self._build_ui()
        self.root.after(250, self._process_queue)

    def _build_ui(self) -> None:
        self.root.title("ISS Radio Monitor")
        self.root.geometry("1180x760")
        self.root.minsize(980, 640)

        main = ttk.Frame(self.root, padding=12)
        main.pack(fill=tk.BOTH, expand=True)

        headline = ttk.Label(
            main,
            text="Monitor ISS pentru statii SDR VHF",
            font=("TkDefaultFont", 16, "bold"),
        )
        headline.pack(anchor=tk.W)

        subtitle = ttk.Label(
            main,
            text="Frecventa urmarita: 145.800 MHz FM (ARISS voice / SSTV)",
        )
        subtitle.pack(anchor=tk.W, pady=(4, 10))

        controls = ttk.Frame(main)
        controls.pack(fill=tk.X, pady=(0, 10))

        self.start_button = ttk.Button(
            controls,
            text="Porneste monitorizarea",
            command=self.start_monitoring,
        )
        self.start_button.pack(side=tk.LEFT)

        self.stop_button = ttk.Button(
            controls,
            text="Opreste",
            command=self.stop_monitoring,
            state=tk.DISABLED,
        )
        self.stop_button.pack(side=tk.LEFT, padx=(8, 0))

        refresh_button = ttk.Button(
            controls,
            text="Actualizeaza acum",
            command=self.refresh_now,
        )
        refresh_button.pack(side=tk.LEFT, padx=(8, 0))

        open_recommended_button = ttk.Button(
            controls,
            text="Deschide statia recomandata",
            command=self.open_recommended,
        )
        open_recommended_button.pack(side=tk.LEFT, padx=(8, 0))

        open_selected_button = ttk.Button(
            controls,
            text="Deschide statia selectata",
            command=self.open_selected_station,
        )
        open_selected_button.pack(side=tk.LEFT, padx=(8, 0))

        ttk.Label(controls, text="Interval (sec):").pack(side=tk.LEFT, padx=(16, 4))
        interval_box = ttk.Spinbox(
            controls,
            from_=5,
            to=600,
            increment=5,
            textvariable=self.interval_var,
            width=6,
        )
        interval_box.pack(side=tk.LEFT)

        auto_open = ttk.Checkbutton(
            controls,
            text="Deschide automat browserul la alerta",
            variable=self.auto_open_var,
        )
        auto_open.pack(side=tk.LEFT, padx=(16, 0))

        info_frame = ttk.LabelFrame(main, text="Stare curenta", padding=10)
        info_frame.pack(fill=tk.X)

        info_grid = ttk.Frame(info_frame)
        info_grid.pack(fill=tk.X)
        info_grid.columnconfigure(1, weight=1)
        info_grid.columnconfigure(3, weight=1)

        fields = (
            ("Monitorizare", self.monitor_state_var),
            ("Pozitie ISS", self.iss_position_var),
            ("Altitudine", self.iss_altitude_var),
            ("Viteza", self.iss_speed_var),
            ("Raza radio", self.iss_coverage_var),
            ("Sursa date", self.provider_var),
            ("Actualizat", self.updated_var),
        )
        for row_index, (label_text, variable) in enumerate(fields):
            column = 0 if row_index < 4 else 2
            current_row = row_index if row_index < 4 else row_index - 4
            ttk.Label(info_grid, text=f"{label_text}:").grid(
                row=current_row,
                column=column,
                sticky="w",
                padx=(0, 6),
                pady=2,
            )
            ttk.Label(info_grid, textvariable=variable).grid(
                row=current_row,
                column=column + 1,
                sticky="w",
                padx=(0, 18),
                pady=2,
            )

        recommendation_frame = ttk.LabelFrame(
            main, text="Statie recomandata", padding=10
        )
        recommendation_frame.pack(fill=tk.X, pady=(10, 0))

        ttk.Label(
            recommendation_frame,
            textvariable=self.recommendation_var,
            wraplength=1120,
            justify=tk.LEFT,
        ).pack(anchor=tk.W)

        recommendation_link = ttk.Label(
            recommendation_frame,
            textvariable=self.recommendation_url_var,
            foreground="blue",
            cursor="hand2",
        )
        recommendation_link.pack(anchor=tk.W, pady=(4, 0))
        recommendation_link.bind("<Button-1>", lambda _event: self.open_recommended())

        status_frame = ttk.LabelFrame(main, text="Jurnal / stare", padding=10)
        status_frame.pack(fill=tk.X, pady=(10, 0))
        ttk.Label(
            status_frame,
            textvariable=self.status_var,
            wraplength=1120,
            justify=tk.LEFT,
        ).pack(anchor=tk.W)

        table_frame = ttk.LabelFrame(main, text="Statii SDR urmarite", padding=10)
        table_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))

        columns = ("name", "location", "status", "distance", "margin", "url")
        self.tree = ttk.Treeview(
            table_frame,
            columns=columns,
            show="headings",
            selectmode="browse",
        )
        headings = {
            "name": "Statie",
            "location": "Locatie",
            "status": "Status",
            "distance": "Distanta ISS (km)",
            "margin": "Marja fata de raza (km)",
            "url": "URL",
        }
        widths = {
            "name": 210,
            "location": 190,
            "status": 90,
            "distance": 120,
            "margin": 150,
            "url": 330,
        }
        for column in columns:
            self.tree.heading(column, text=headings[column])
            self.tree.column(column, width=widths[column], anchor=tk.W)

        self.tree.tag_configure("in_range", background="#e8f6ec")
        self.tree.tag_configure("out_range", background="#f8f8f8")

        scrollbar = ttk.Scrollbar(
            table_frame, orient=tk.VERTICAL, command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=scrollbar.set)
        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        self.tree.bind("<Double-1>", lambda _event: self.open_selected_station())
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)

    def set_status(self, message: str) -> None:
        self.status_var.set(message)

    def start_monitoring(self) -> None:
        if self.monitoring:
            return
        self.monitoring = True
        self.current_in_range = set()
        self.monitor_state_var.set("Pornit")
        self.start_button.config(state=tk.DISABLED)
        self.stop_button.config(state=tk.NORMAL)
        self.set_status("Monitorizarea a pornit. Verific pozitia ISS periodic.")
        self._schedule_fetch(0)

    def stop_monitoring(self) -> None:
        self.monitoring = False
        self.current_in_range = set()
        self.monitor_state_var.set("Oprit")
        self.start_button.config(state=tk.NORMAL)
        self.stop_button.config(state=tk.DISABLED)
        if self.after_id is not None:
            self.root.after_cancel(self.after_id)
            self.after_id = None
        self.set_status("Monitorizarea a fost oprita.")

    def refresh_now(self) -> None:
        if self.after_id is not None:
            self.root.after_cancel(self.after_id)
            self.after_id = None
        self._schedule_fetch(0)

    def _schedule_fetch(self, delay_ms: int) -> None:
        if self.fetch_in_progress:
            return
        self.after_id = self.root.after(delay_ms, self._start_fetch_thread)

    def _start_fetch_thread(self) -> None:
        self.after_id = None
        if self.fetch_in_progress:
            return
        self.fetch_in_progress = True
        thread = threading.Thread(target=self._fetch_cycle, daemon=True)
        thread.start()

    def _fetch_cycle(self) -> None:
        try:
            snapshot = fetch_iss_snapshot(self.session)
            observations = evaluate_stations(snapshot, self.stations)
            self.queue.put(("snapshot", (snapshot, observations)))
        except Exception as exc:
            self.queue.put(("error", str(exc)))

    def _process_queue(self) -> None:
        try:
            while True:
                message_type, payload = self.queue.get_nowait()
                self.fetch_in_progress = False
                if message_type == "snapshot":
                    snapshot, observations = payload
                    self._apply_snapshot(snapshot, observations)
                    if self.monitoring:
                        delay_ms = max(5, int(self.interval_var.get())) * 1000
                        self._schedule_fetch(delay_ms)
                elif message_type == "error":
                    self.set_status(payload)
                    if self.monitoring:
                        delay_ms = max(5, int(self.interval_var.get())) * 1000
                        self._schedule_fetch(delay_ms)
        except Empty:
            pass
        self.root.after(250, self._process_queue)

    def _apply_snapshot(
        self, snapshot: IssSnapshot, observations: list[StationObservation]
    ) -> None:
        self.last_snapshot = snapshot
        self.last_observations = observations

        self.iss_position_var.set(format_coords(snapshot.latitude, snapshot.longitude))
        self.iss_altitude_var.set(f"{snapshot.altitude_km:.1f} km")
        self.iss_speed_var.set(f"{snapshot.speed_kmh:.0f} km/h")
        self.iss_coverage_var.set(f"{snapshot.coverage_radius_km:.0f} km")
        self.provider_var.set(snapshot.provider)
        self.updated_var.set(format_local_time(snapshot.timestamp))

        active = [item for item in observations if item.in_range]
        self._refresh_table(observations)

        if active:
            best = active[0]
            self.recommendation_var.set(
                "ISS este in raza de actiune. "
                f"Recomandat: {best.station.name} din {best.station.location}. "
                f"Distanta curenta fata de subpunctul ISS: {best.distance_km:.0f} km. "
                f"Acordeaza pe {FREQUENCY_MHZ:.3f} MHz {MODE}."
            )
            self.recommendation_url_var.set(best.station.url)
        elif observations:
            nearest = observations[0]
            self.recommendation_var.set(
                "Momentan niciuna dintre statiile urmarite nu este in raza. "
                f"Cea mai apropiata este {nearest.station.name} din "
                f"{nearest.station.location}, la {nearest.distance_km:.0f} km."
            )
            self.recommendation_url_var.set(nearest.station.url)
        else:
            self.recommendation_var.set("Nu exista statii configurate.")
            self.recommendation_url_var.set("")

        self.set_status(summarize_snapshot(snapshot, observations))

        current_ids = {item.station.name for item in active}
        new_ids = current_ids - self.current_in_range
        if new_ids and self.monitoring:
            self._trigger_alert(active, new_ids)
        self.current_in_range = current_ids

    def _refresh_table(self, observations: list[StationObservation]) -> None:
        selected_station = self._selected_station_name()
        self.tree.delete(*self.tree.get_children())
        for item in observations:
            tag = "in_range" if item.in_range else "out_range"
            status = "IN RAZA" if item.in_range else "departe"
            self.tree.insert(
                "",
                tk.END,
                iid=item.station.name,
                values=(
                    item.station.name,
                    item.station.location,
                    status,
                    f"{item.distance_km:.0f}",
                    f"{item.margin_km:+.0f}",
                    item.station.url,
                ),
                tags=(tag,),
            )
        if selected_station and self.tree.exists(selected_station):
            self.tree.selection_set(selected_station)
        elif observations:
            self.tree.selection_set(observations[0].station.name)

    def _trigger_alert(
        self, active: list[StationObservation], new_ids: set[str]
    ) -> None:
        best = active[0]
        title = "ISS in raza unei statii SDR"
        message = (
            f"Statie recomandata: {best.station.name}\n"
            f"Locatie: {best.station.location}\n"
            f"Asculta pe {FREQUENCY_MHZ:.3f} MHz {MODE}\n"
            f"URL: {best.station.url}"
        )
        play_alarm_sound(self.alarm_file)
        send_desktop_notification(title, message)
        self.status_var.set(
            "ALERTA: ISS a intrat in raza statiei "
            f"{best.station.name}. Deschide linkul recomandat."
        )
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append(best.station.url)
        except tk.TclError:
            pass
        if self.auto_open_var.get():
            webbrowser.open(best.station.url, new=2)

    def _selected_station_name(self) -> str | None:
        selection = self.tree.selection()
        return selection[0] if selection else None

    def open_selected_station(self) -> None:
        selected = self._selected_station_name()
        if not selected:
            return
        for station in self.stations:
            if station.name == selected:
                webbrowser.open(station.url, new=2)
                return

    def open_recommended(self) -> None:
        url = self.recommendation_url_var.get().strip()
        if url:
            webbrowser.open(url, new=2)

    def on_close(self) -> None:
        self.monitoring = False
        if self.after_id is not None:
            self.root.after_cancel(self.after_id)
            self.after_id = None
        self.session.close()
        self.root.destroy()


def run_once(stations: list[Station]) -> int:
    session = requests.Session()
    session.headers.update({"User-Agent": "ISS-Radio-Monitor/1.0 (+cli)"})
    snapshot = fetch_iss_snapshot(session)
    observations = evaluate_stations(snapshot, stations)
    print(summarize_snapshot(snapshot, observations))
    print()
    for item in observations:
        status = "IN RAZA" if item.in_range else "departe"
        print(
            f"{item.station.name:24} | {status:8} | "
            f"{item.distance_km:7.0f} km | marja {item.margin_km:+7.0f} km | "
            f"{item.station.url}"
        )
    return 0


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Monitorizeaza ISS si alerteaza cand intra in raza unor statii "
            "SDR VHF care pot receptiona 145.800 MHz."
        )
    )
    parser.add_argument(
        "--stations-file",
        default=str(STATIONS_FILE),
        help="Fisier JSON cu statiile de urmarit.",
    )
    parser.add_argument(
        "--once",
        action="store_true",
        help="Face o singura verificare in terminal, fara GUI.",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv or sys.argv[1:])
    stations = load_stations(Path(args.stations_file))
    if args.once:
        return run_once(stations)
    root = tk.Tk()
    IssRadioMonitorApp(root, stations)
    root.mainloop()
    return 0


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