"""HTTP server for trajectory viewer using Python stdlib."""

from __future__ import annotations

import errno
import json
import logging
import re
import socket
import sys
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse

from rich.console import Console

from harbor.viewer.scanner import DirectoryScanner

logger = logging.getLogger(__name__)
console = Console()
console_err = Console(stderr=True)


class PortError(Exception):
    """Base exception for port-related errors."""

    pass


class PortInUseError(PortError):
    """Raised when requested port(s) are already in use."""

    def __init__(self, ports: list[int]):
        self.ports = ports
        if len(ports) == 1:
            super().__init__(f"Port {ports[0]} is already in use")
        else:
            super().__init__(f"All ports in range {ports[0]}-{ports[-1]} are in use")


class PortPermissionError(PortError):
    """Raised when port access is denied (e.g., privileged ports without root)."""

    def __init__(self, port: int):
        self.port = port
        super().__init__(f"Permission denied for port {port} (try a port > 1024)")


def is_port_free(port: int, host: str = "127.0.0.1") -> bool | None:
    """Check if a port is available for binding.

    Uses the "bind and close" method which is more reliable than connect().
    HTTPServer uses SO_REUSEADDR by default, so we match that behavior.

    Returns:
        True if port is free, False if in use, None if permission denied.
    """
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            s.bind((host, port))
            return True
        except OSError as e:
            if e.errno == errno.EACCES:
                return None  # Permission denied
            return False  # Port in use


def find_available_port(
    host: str,
    ports: list[int],
    verbose: bool = True,
) -> int:
    """Find the first available port from a list of candidates.

    Args:
        host: Host to check ports on.
        ports: List of port numbers to try in order.
        verbose: If True (and multiple ports), print status to stderr.

    Returns:
        First available port.

    Raises:
        PortInUseError: If all ports are occupied.
        PortPermissionError: If a port requires elevated permissions.
    """
    is_range = len(ports) > 1

    for i, port in enumerate(ports):
        status = is_port_free(port, host)
        if status is True:
            return port
        if status is None:
            raise PortPermissionError(port)
        # status is False (port in use)
        if verbose and is_range:
            next_port = ports[i + 1] if i + 1 < len(ports) else None
            if next_port:
                console_err.print(
                    f"[yellow]Port {port} in use[/yellow], trying {next_port}..."
                )
            else:
                console_err.print(f"[yellow]Port {port} in use[/yellow]")

    raise PortInUseError(ports)


class TrajectoryHandler(BaseHTTPRequestHandler):
    """HTTP request handler for trajectory viewer API."""

    scanner: DirectoryScanner | None = None

    def log_message(self, format: str, *args: Any) -> None:
        """Override to use logging module instead of stderr."""
        logger.debug("%s - %s", self.address_string(), format % args)

    def do_GET(self) -> None:
        """Handle GET requests with URL routing."""
        path = urlparse(self.path).path

        # Route matching
        if path == "/":
            self._serve_index()
        elif path == "/api/jobs":
            self._handle_list_jobs()
        elif match := re.match(r"^/api/jobs/([^/]+)/trials$", path):
            self._handle_list_trials(unquote(match.group(1)))
        elif match := re.match(r"^/api/jobs/([^/]+)/config$", path):
            self._handle_get_config(unquote(match.group(1)))
        elif match := re.match(r"^/api/jobs/([^/]+)/log$", path):
            self._handle_get_job_log(unquote(match.group(1)))
        elif match := re.match(r"^/api/trajectories/([^/]+)/([^/]+)$", path):
            self._handle_get_trajectory(
                unquote(match.group(1)), unquote(match.group(2))
            )
        elif match := re.match(r"^/api/trajectories/([^/]+)/([^/]+)/mtime$", path):
            self._handle_get_mtime(unquote(match.group(1)), unquote(match.group(2)))
        elif match := re.match(r"^/static/([^/]+)$", path):
            self._serve_static(unquote(match.group(1)))
        else:
            self._send_not_found()

    def _send_json(self, data: Any, status: int = 200) -> None:
        """Send JSON response with proper headers."""
        body = json.dumps(data).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_not_found(self) -> None:
        """Send 404 response."""
        self._send_json({"error": "Not found"}, 404)

    def _serve_index(self) -> None:
        """Serve the viewer.html file."""
        html_path = Path(__file__).parent / "templates" / "viewer.html"
        content = html_path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)

    def _handle_list_jobs(self) -> None:
        """GET /api/jobs - List all jobs."""
        if self.scanner is None:
            self._send_json({"error": "Scanner not initialized"}, 500)
            return

        jobs = self.scanner.scan_jobs()
        data = [j.model_dump(exclude={"job_path"}) for j in jobs]
        self._send_json(data)

    def _handle_list_trials(self, job_id: str) -> None:
        """GET /api/jobs/{job_id}/trials - List trials for a job."""
        if self.scanner is None:
            self._send_json({"error": "Scanner not initialized"}, 500)
            return

        trials = self.scanner.scan_trials(job_id)
        data = [t.model_dump(exclude={"trial_path"}) for t in trials]
        self._send_json(data)

    def _handle_get_config(self, job_id: str) -> None:
        """GET /api/jobs/{job_id}/config - Get job config.json."""
        if self.scanner is None:
            self._send_json({"error": "Scanner not initialized"}, 500)
            return

        job_path = self.scanner._resolve_job_path(job_id)
        if not job_path:
            self._send_json(None)
            return

        config_path = job_path / "config.json"
        if config_path.exists():
            try:
                self._send_json(json.loads(config_path.read_text()))
                return
            except Exception:
                pass
        self._send_json(None)

    def _handle_get_job_log(self, job_id: str) -> None:
        """GET /api/jobs/{job_id}/log - Get job.log content."""
        if self.scanner is None:
            self._send_json({"error": "Scanner not initialized"}, 500)
            return

        job_path = self.scanner._resolve_job_path(job_id)
        if not job_path:
            self._send_json({"error": "Job not found"}, 404)
            return

        log_path = job_path / "job.log"
        if log_path.exists():
            try:
                content = log_path.read_text(errors="replace")
                self._send_json({"content": content})
                return
            except Exception:
                pass
        self._send_json({"content": None})

    def _handle_get_trajectory(self, job_id: str, trial_id: str) -> None:
        """GET /api/trajectories/{job_id}/{trial_id} - Get full trajectory data."""
        if self.scanner is None:
            self._send_json({"error": "Scanner not initialized"}, 500)
            return

        data = self.scanner.get_trajectory_data(job_id, trial_id)
        if data is None:
            self._send_json({"error": "Trajectory not found"}, 404)
            return

        self._send_json(data.model_dump())

    def _handle_get_mtime(self, job_id: str, trial_id: str) -> None:
        """GET /api/trajectories/{job_id}/{trial_id}/mtime - Get modification time."""
        if self.scanner is None:
            self._send_json({"error": "Scanner not initialized"}, 500)
            return

        mtime = self.scanner.get_trajectory_mtime(job_id, trial_id)
        self._send_json({"mtime": mtime})

    def _serve_static(self, filename: str) -> None:
        """Serve static files (vendored JS/CSS dependencies)."""
        static_dir = Path(__file__).parent / "static"
        file_path = static_dir / filename

        # Security: ensure file is within static directory
        try:
            file_path = file_path.resolve()
            if not file_path.is_relative_to(static_dir.resolve()):
                self._send_not_found()
                return
        except (ValueError, RuntimeError):
            self._send_not_found()
            return

        if not file_path.exists() or not file_path.is_file():
            self._send_not_found()
            return

        # Determine content type
        content_type = "application/octet-stream"
        if filename.endswith(".js"):
            content_type = "application/javascript; charset=utf-8"
        elif filename.endswith(".css"):
            content_type = "text/css; charset=utf-8"

        content = file_path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)


def start_server(
    folder: Path,
    ports: list[int],
    host: str = "127.0.0.1",
    open_browser: bool = True,
) -> None:
    """Start the trajectory viewer server.

    Args:
        folder: Directory to scan for jobs/trials with trajectories.
        ports: List of ports to try in order. First available port is used.
        host: Host to bind the server to.
        open_browser: Whether to open a browser automatically.
    """

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(levelname)s - %(message)s",
        datefmt="%H:%M:%S",
    )

    if not folder.exists():
        logger.error(f"Folder not found: {folder}")
        sys.exit(1)

    # Find an available port (raises PortError if none available)
    port = find_available_port(host, ports)

    # Initialize scanner on the handler class
    TrajectoryHandler.scanner = DirectoryScanner(folder)

    # Scan and show summary
    jobs = TrajectoryHandler.scanner.scan_jobs()
    total_trials = sum(j.trial_count for j in jobs)

    url = f"http://{host}:{port}"
    console.print()
    console.print("[bold]Harbor Viewer[/bold]")
    jobs_word = "job" if len(jobs) == 1 else "jobs"
    trials_word = "trial" if total_trials == 1 else "trials"

    console.print(f"  [cyan]URL:[/cyan]      [link={url}]{url}[/link]")
    console.print(f"  [cyan]Watching:[/cyan] {folder}")
    console.print(
        f"  [cyan]Found:[/cyan]    {len(jobs)} {jobs_word}, {total_trials} {trials_word}"
    )
    console.print()
    console.print("[dim]Press Ctrl+C to stop[/dim]")
    console.print()

    if open_browser:
        webbrowser.open(url)

    # Run HTTP server
    server = HTTPServer((host, port), TrajectoryHandler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        console.print("\n[dim]Shutting down...[/dim]")
        server.shutdown()
