"""Log command for scry-run.

Watches an app's log directory and tails log files as they update.
"""

import datetime
import re
import sys
import time
import threading
from dataclasses import dataclass
from pathlib import Path

import click
from rich.console import Console
from rich.text import Text

from scry_run.home import get_app_dir, get_app_logs

console = Console()

_COLOR_PALETTE = [
    "cyan",
    "magenta",
    "green",
    "yellow",
    "blue",
    "bright_cyan",
    "bright_magenta",
    "bright_green",
    "bright_yellow",
    "bright_blue",
]

_LEVEL_COLORS = {
    "DEBUG": "dim",
    "INFO": "green",
    "WARN": "yellow",
    "WARNING": "yellow",
    "ERROR": "red",
}

_LOG_HEADER_RE = re.compile(r"^\[(?P<ts>[^\]]+)\]\s+\[(?P<level>[A-Z]+)\]\s+")


_DURATION_RE = re.compile(r"^\d+(?:\.\d+)?[smh]?$")


def _parse_duration(value: str) -> float:
    """Parse a duration like '30s', '10m', '1h' into seconds."""
    s = value.strip().lower()
    if not s:
        raise ValueError("duration cannot be empty")

    unit = s[-1]
    if unit.isdigit():
        # seconds by default
        return float(s)

    number = s[:-1]
    if not number:
        raise ValueError(f"invalid duration: {value}")

    try:
        qty = float(number)
    except ValueError as exc:
        raise ValueError(f"invalid duration: {value}") from exc

    if unit == "s":
        return qty
    if unit == "m":
        return qty * 60.0
    if unit == "h":
        return qty * 3600.0

    raise ValueError(f"invalid duration unit: {value}")


def _normalize_dt(value: datetime.datetime) -> datetime.datetime:
    if value.tzinfo is None:
        return value
    return value.astimezone().replace(tzinfo=None)


def _parse_since(value: str, now: datetime.datetime) -> datetime.datetime:
    s = value.strip()
    if not s:
        raise ValueError("since cannot be empty")

    if _DURATION_RE.match(s.lower()):
        seconds = _parse_duration(s)
        return now - datetime.timedelta(seconds=seconds)

    try:
        parsed = datetime.datetime.fromisoformat(s)
    except ValueError as exc:
        raise ValueError(f"invalid since value: {value}") from exc

    return _normalize_dt(parsed)


def _parse_log_timestamp(line: str) -> datetime.datetime | None:
    raw = line.lstrip()
    if not raw.startswith("["):
        return None
    end = raw.find("]")
    if end == -1:
        return None
    ts_str = raw[1:end]
    try:
        return _normalize_dt(datetime.datetime.fromisoformat(ts_str))
    except ValueError:
        return None


@dataclass
class _TailState:
    path: Path
    position: int
    buffer: str


class _OutputLock:
    def __init__(self) -> None:
        self._lock = threading.Lock()

    def write(self, data: Text | str) -> None:
        with self._lock:
            if isinstance(data, Text):
                console.print(data, end="")
            else:
                sys.stdout.write(data)
                sys.stdout.flush()


def _iter_recent_logs(log_dir: Path, since_seconds: float) -> list[Path]:
    now = time.time()
    paths: list[Path] = []
    for path in sorted(log_dir.glob("*.log")):
        try:
            if (now - path.stat().st_mtime) <= since_seconds:
                paths.append(path)
        except FileNotFoundError:
            continue
    return paths


def _read_new_data(state: _TailState) -> list[str]:
    try:
        size = state.path.stat().st_size
    except FileNotFoundError:
        return []

    if size < state.position:
        # File truncated or rotated; start over
        state.position = 0
        state.buffer = ""

    try:
        with state.path.open("r", encoding="utf-8", errors="replace") as f:
            f.seek(state.position)
            data = f.read()
            state.position = f.tell()
    except FileNotFoundError:
        return []

    if not data:
        return []

    chunk = state.buffer + data
    lines = chunk.splitlines(keepends=True)
    output: list[str] = []
    state.buffer = ""
    for line in lines:
        if line.endswith("\n") or line.endswith("\r"):
            output.append(line)
        else:
            state.buffer = line
    return output


def _color_for_name(name: str) -> str:
    return _COLOR_PALETTE[hash(name) % len(_COLOR_PALETTE)]


def _build_colored_lines(path: Path, lines: list[str], short_mode: bool = False) -> Text:
    text = Text()
    prefix = "> " if short_mode else f"[{path.name}] "
    prefix_style = "dim" if short_mode else _color_for_name(path.name)

    for line in lines:
        text.append(prefix, style=prefix_style)

        stripped = line.strip()
        if stripped and all(ch == "=" for ch in stripped):
            text.append(line, style="dim")
            continue
        if stripped and all(ch == "-" for ch in stripped):
            text.append(line, style="dim")
            continue

        match = _LOG_HEADER_RE.match(line)
        if match:
            ts = match.group("ts")
            level = match.group("level")
            level_style = _LEVEL_COLORS.get(level, "white")
            header_len = match.end()
            rest = line[header_len:]
            if not short_mode:
                text.append(f"[{ts}] ", style="dim")
            text.append(f"[{level}] ", style=level_style)
            text.append(rest)
            continue

        text.append(line)

    return text


def _find_offset_for_since(path: Path, cutoff: datetime.datetime) -> int:
    try:
        with path.open("r", encoding="utf-8", errors="replace") as f:
            prev_pos = 0
            prev_line_sep = False
            while True:
                pos = f.tell()
                line = f.readline()
                if not line:
                    return f.tell()

                ts = _parse_log_timestamp(line)
                if ts and ts >= cutoff:
                    return prev_pos if prev_line_sep else pos

                prev_line_sep = line.strip() == "=" * 80
                prev_pos = pos
    except FileNotFoundError:
        return 0


def _wait_for_log_dir(
    app_name: str,
    *,
    poll_interval: float = 0.2,
    max_wait_seconds: float | None = None,
) -> Path:
    """Wait for an app's logs directory to be created.

    By default this waits indefinitely. ``max_wait_seconds`` is only intended
    for tests to avoid hanging.
    """
    app_dir = get_app_dir(app_name)
    log_dir = get_app_logs(app_name)

    warned = False
    started = time.monotonic()

    while True:
        if log_dir.exists() and log_dir.is_dir():
            if warned:
                console.print(f"[dim]Found logs directory: {log_dir}[/dim]")
            return log_dir

        if not warned:
            if not app_dir.exists():
                console.print(f"[yellow]Warning:[/yellow] App '{app_name}' not found yet.")
                console.print(f"[dim]Waiting for logs directory: {log_dir}[/dim]")
            else:
                console.print(f"[yellow]Warning:[/yellow] Logs directory for app '{app_name}' not found yet.")
                console.print(f"[dim]Waiting for logs directory: {log_dir}[/dim]")
            warned = True

        if max_wait_seconds is not None and (time.monotonic() - started) >= max_wait_seconds:
            raise TimeoutError(f"Timed out waiting for logs directory: {log_dir}")

        time.sleep(poll_interval)


@click.command("log")
@click.argument("app_name")
@click.option(
    "--since",
    default="1m",
    show_default=True,
    help="Start from entries since this time (e.g., 30m, 1h, 10s, or 2026-02-04T12:30:00).",
)
@click.option(
    "--debounce",
    default=0.2,
    show_default=True,
    type=float,
    help="Debounce time in seconds for filesystem events.",
)
@click.option(
    "--short",
    "short_mode",
    is_flag=True,
    help="Use compact output prefix ('> ') instead of log filename.",
)
def log_cmd(app_name: str, since: str, debounce: float, short_mode: bool) -> None:
    """Watch an app's log directory and tail log files."""
    try:
        from watchfiles import Change, watch
    except ModuleNotFoundError:
        console.print("[red]Error:[/red] Missing dependency: watchfiles")
        console.print("[dim]Install/update scry-run to include watchfiles, then retry.[/dim]")
        sys.exit(1)

    try:
        log_dir = _wait_for_log_dir(app_name)
    except KeyboardInterrupt:
        console.print("\n[dim]Stopped waiting for logs.[/dim]")
        return

    now = datetime.datetime.now()
    try:
        since_cutoff = _parse_since(since, now)
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if debounce < 0:
        console.print("[red]Error:[/red] debounce must be >= 0")
        sys.exit(1)

    debounce_ms = int(debounce * 1000)

    console.print(f"[cyan]Watching logs in:[/cyan] {log_dir}")
    console.print(f"[dim]Since:[/dim] {since}  [dim]Press Ctrl+C to stop[/dim]")

    states: dict[Path, _TailState] = {}
    output_lock = _OutputLock()

    # Seed with recent log files based on mtime, then seek to cutoff by entry timestamp
    for path in _iter_recent_logs(log_dir, (now - since_cutoff).total_seconds()):
        offset = _find_offset_for_since(path, since_cutoff)
        states[path] = _TailState(path=path, position=offset, buffer="")

    # Print existing entries since cutoff on startup
    for path, state in states.items():
        lines = _read_new_data(state)
        if not lines:
            continue
        output_lock.write(_build_colored_lines(path, lines, short_mode=short_mode))

    try:
        for changes in watch(log_dir, debounce=debounce_ms):
            paths_to_read: set[Path] = set()

            for change, raw_path in changes:
                path = Path(raw_path)
                if path.suffix != ".log":
                    continue

                if change == Change.deleted:
                    states.pop(path, None)
                    continue

                if path not in states:
                    offset = _find_offset_for_since(path, since_cutoff)
                    states[path] = _TailState(path=path, position=offset, buffer="")
                paths_to_read.add(path)

            for path in paths_to_read:
                state = states.get(path)
                if not state:
                    continue
                lines = _read_new_data(state)
                if not lines:
                    continue
                output_lock.write(_build_colored_lines(path, lines, short_mode=short_mode))
    except KeyboardInterrupt:
        console.print("\n[dim]Stopped watching logs.[/dim]")
