"""
Retention and cleanup for the Terminal Broker.

Manages message archiving, stale session cleanup, channel compaction,
and broker-wide statistics. All functions are synchronous.

Storage paths:
  Active:   state/broker/channels/{channel}.jsonl
  Archive:  state/broker/archive/{channel}_{YYYY-MM-DD}.jsonl
  Sessions: state/broker/sessions/{session_id}.json
  Cursors:  state/broker/cursors/{session_id}_{channel}.cursor
"""

import json
import os
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional

from brynq.server import BROKER_DIR, CHANNELS_DIR, CURSORS_DIR, SESSIONS_DIR

ARCHIVE_DIR = BROKER_DIR / "archive"
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)


def archive_channel(channel: str, before_date: Optional[datetime] = None) -> dict:
    """Move messages older than `before_date` to an archive file.

    Messages with timestamps strictly before `before_date` are appended
    to ``state/broker/archive/{channel}_{YYYY-MM-DD}.jsonl`` and removed
    from the active channel file.  Recent messages are kept in place.

    Args:
        channel: Channel name (without ``.jsonl`` extension).
        before_date: Cutoff datetime (UTC).  Messages older than this are
            archived.  Defaults to 7 days ago.

    Returns:
        Dict with keys ``archived``, ``remaining``, and ``archive_path``.
    """
    if before_date is None:
        before_date = datetime.now(timezone.utc) - timedelta(days=7)

    # Ensure before_date is timezone-aware (treat naive as UTC)
    if before_date.tzinfo is None:
        before_date = before_date.replace(tzinfo=timezone.utc)

    channel_path = CHANNELS_DIR / f"{channel}.jsonl"
    if not channel_path.exists():
        return {"archived": 0, "remaining": 0, "archive_path": ""}

    archive_name = f"{channel}_{before_date.strftime('%Y-%m-%d')}.jsonl"
    archive_path = ARCHIVE_DIR / archive_name

    keep: list[str] = []
    archive: list[str] = []

    for raw_line in channel_path.read_text("utf-8").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
        except json.JSONDecodeError:
            # Malformed lines stay in the active file (compaction handles them)
            keep.append(line)
            continue

        ts_str = msg.get("timestamp", "")
        try:
            ts = datetime.fromisoformat(ts_str)
            if ts.tzinfo is None:
                ts = ts.replace(tzinfo=timezone.utc)
        except (ValueError, TypeError):
            # Unparseable timestamp -- keep in active file
            keep.append(line)
            continue

        if ts < before_date:
            archive.append(line)
        else:
            keep.append(line)

    # Append archived messages (don't overwrite previous archives for same date)
    if archive:
        with archive_path.open("a", encoding="utf-8") as f:
            for line in archive:
                f.write(line + "\n")

    # Atomically rewrite the active channel file
    _atomic_write_lines(channel_path, keep)

    return {
        "archived": len(archive),
        "remaining": len(keep),
        "archive_path": str(archive_path) if archive else "",
    }


def clean_stale_sessions(max_age_hours: int = 24) -> list[str]:
    """Remove session and cursor files for sessions with stale heartbeats.

    A session is considered stale when its ``heartbeat`` timestamp is
    older than ``max_age_hours`` from now.

    Args:
        max_age_hours: Maximum heartbeat age in hours before a session is
            considered stale and eligible for cleanup.

    Returns:
        List of cleaned session IDs.
    """
    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(hours=max_age_hours)
    cleaned: list[str] = []

    if not SESSIONS_DIR.exists():
        return cleaned

    for session_file in list(SESSIONS_DIR.glob("*.json")):
        try:
            data = json.loads(session_file.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            # Unreadable session file -- remove it
            session_id = session_file.stem
            _safe_remove(session_file)
            _remove_cursors_for_session(session_id)
            cleaned.append(session_id)
            continue

        heartbeat_str = data.get("heartbeat", "")
        session_id = data.get("session_id", session_file.stem)

        try:
            heartbeat = datetime.fromisoformat(heartbeat_str)
            if heartbeat.tzinfo is None:
                heartbeat = heartbeat.replace(tzinfo=timezone.utc)
        except (ValueError, TypeError):
            # Unparseable heartbeat -- treat as stale
            _safe_remove(session_file)
            _remove_cursors_for_session(session_id)
            cleaned.append(session_id)
            continue

        if heartbeat < cutoff:
            _safe_remove(session_file)
            _remove_cursors_for_session(session_id)
            cleaned.append(session_id)

    return cleaned


def get_stats() -> dict:
    """Return broker-wide statistics.

    Returns:
        Dict containing channel count, total messages, active and stale
        session counts, oldest/newest message timestamps, total storage
        bytes, and archive file count.
    """
    now = datetime.now(timezone.utc)

    # Channel stats
    channel_count = 0
    total_messages = 0
    oldest_message: Optional[str] = None
    newest_message: Optional[str] = None
    storage_bytes = 0

    if CHANNELS_DIR.exists():
        for f in CHANNELS_DIR.glob("*.jsonl"):
            channel_count += 1
            try:
                size = f.stat().st_size
            except OSError:
                size = 0
            storage_bytes += size

            for raw_line in f.read_text("utf-8").splitlines():
                line = raw_line.strip()
                if not line:
                    continue
                total_messages += 1
                try:
                    msg = json.loads(line)
                except json.JSONDecodeError:
                    continue
                ts_str = msg.get("timestamp", "")
                if not ts_str:
                    continue
                if oldest_message is None or ts_str < oldest_message:
                    oldest_message = ts_str
                if newest_message is None or ts_str > newest_message:
                    newest_message = ts_str

    # Session stats
    active_sessions = 0
    stale_sessions = 0
    stale_cutoff = now - timedelta(hours=24)

    if SESSIONS_DIR.exists():
        for f in SESSIONS_DIR.glob("*.json"):
            try:
                data = json.loads(f.read_text("utf-8"))
            except (json.JSONDecodeError, OSError):
                stale_sessions += 1
                continue

            hb_str = data.get("heartbeat", "")
            try:
                hb = datetime.fromisoformat(hb_str)
                if hb.tzinfo is None:
                    hb = hb.replace(tzinfo=timezone.utc)
                if hb >= stale_cutoff:
                    active_sessions += 1
                else:
                    stale_sessions += 1
            except (ValueError, TypeError):
                stale_sessions += 1

    # Archive stats
    archive_count = 0
    if ARCHIVE_DIR.exists():
        archive_count = len(list(ARCHIVE_DIR.glob("*.jsonl")))
        for f in ARCHIVE_DIR.glob("*.jsonl"):
            try:
                storage_bytes += f.stat().st_size
            except OSError:
                pass

    return {
        "channels": channel_count,
        "total_messages": total_messages,
        "active_sessions": active_sessions,
        "stale_sessions": stale_sessions,
        "oldest_message": oldest_message or "",
        "newest_message": newest_message or "",
        "storage_bytes": storage_bytes,
        "archive_count": archive_count,
    }


def purge_channel(channel: str) -> int:
    """Delete all messages in a channel.

    The channel file is truncated to zero bytes.  The caller should
    treat the returned count as a confirmation of what was removed.

    Args:
        channel: Channel name (without ``.jsonl`` extension).

    Returns:
        Number of messages that were deleted.
    """
    channel_path = CHANNELS_DIR / f"{channel}.jsonl"
    if not channel_path.exists():
        return 0

    count = 0
    for raw_line in channel_path.read_text("utf-8").splitlines():
        if raw_line.strip():
            count += 1

    # Truncate the file (keep it so the channel still "exists")
    channel_path.write_text("", encoding="utf-8")
    return count


def compact_channel(channel: str) -> dict:
    """Rewrite a channel file, removing malformed lines.

    Each line is validated as parseable JSON.  Lines that fail to parse
    are silently dropped.  The file is rewritten atomically.

    Args:
        channel: Channel name (without ``.jsonl`` extension).

    Returns:
        Dict with keys ``before``, ``after``, and ``removed``.
    """
    channel_path = CHANNELS_DIR / f"{channel}.jsonl"
    if not channel_path.exists():
        return {"before": 0, "after": 0, "removed": 0}

    raw_lines = channel_path.read_text("utf-8").splitlines()
    before = 0
    valid: list[str] = []

    for raw_line in raw_lines:
        line = raw_line.strip()
        if not line:
            continue
        before += 1
        try:
            json.loads(line)
            valid.append(line)
        except json.JSONDecodeError:
            continue

    _atomic_write_lines(channel_path, valid)

    return {
        "before": before,
        "after": len(valid),
        "removed": before - len(valid),
    }


# ── Internal Helpers ──────────────────────────────────────────────────────


def _atomic_write_lines(path: Path, lines: list[str]) -> None:
    """Write lines to a file atomically via a temporary file + rename."""
    dir_ = path.parent
    try:
        fd, tmp_path = tempfile.mkstemp(dir=str(dir_), suffix=".tmp")
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            for line in lines:
                f.write(line + "\n")
        # os.replace is atomic on the same filesystem
        os.replace(tmp_path, str(path))
    except OSError:
        # Fallback: direct write if atomic rename fails
        with path.open("w", encoding="utf-8") as f:
            for line in lines:
                f.write(line + "\n")


def _safe_remove(path: Path) -> None:
    """Remove a file, ignoring errors if it no longer exists."""
    try:
        path.unlink()
    except FileNotFoundError:
        pass


def _remove_cursors_for_session(session_id: str) -> None:
    """Remove all cursor files belonging to a session."""
    if not CURSORS_DIR.exists():
        return
    for cursor_file in list(CURSORS_DIR.glob(f"{session_id}_*.cursor")):
        _safe_remove(cursor_file)
