#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""GC Daemon — Unified garbage collection for PULSE brain + state (Phase 2).

Handles:
  1. Session rotation: current_session.json at 5K entries -> rotated archive
  2. Capture log rotation: DISABLED (capture logs are append-only golden source)
  3. Orchestrator log rotation: pulse_orchestrator.err at 5MB -> .1 suffix
  4. Archive cleanup: keep only 7 most recent per type
  5. JSONL compaction: remove soft-deleted entries (future)

Usage:
    python -m pulse.daemons.runtime.gc_daemon           # run all GC tasks once
    python -m pulse.daemons.runtime.gc_daemon --dry-run  # show what would happen
"""

import json
import os
import shutil
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))

from pulse.core.brain_utils import (
    STATE_DIR, HIPPOCAMPUS_DIR, ARCHIVE_DIR, CURRENT_SESSION_FILE,
)
from pulse.core.brain_io import _acquire

# Thresholds
SESSION_MAX_ENTRIES = 5000
CAPTURE_MAX_BYTES = 10 * 1024 * 1024   # 10MB
ERR_LOG_MAX_BYTES = 5 * 1024 * 1024    # 5MB
ARCHIVE_KEEP_PER_TYPE = 7


def _log(msg, dry_run=False):
    prefix = "[GC DRY]" if dry_run else "[GC]"
    print(f"{prefix} {msg}")


def rotate_session(dry_run=False) -> dict:
    """Rotate current_session.json when it exceeds SESSION_MAX_ENTRIES."""
    stats = {"action": "session_rotate", "rotated": False, "entries": 0, "size_mb": 0}
    if not CURRENT_SESSION_FILE.exists():
        return stats

    size_mb = round(CURRENT_SESSION_FILE.stat().st_size / 1048576, 1)
    stats["size_mb"] = size_mb

    try:
        with _acquire(CURRENT_SESSION_FILE):
            data = json.loads(CURRENT_SESSION_FILE.read_text(encoding="utf-8"))
            if not isinstance(data, list):
                return stats
            stats["entries"] = len(data)

            if len(data) < SESSION_MAX_ENTRIES:
                _log(f"Session: {len(data)} entries ({size_mb}MB) - below threshold", dry_run)
                return stats

            # Rotate: move current to timestamped archive, reset current
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            archive_path = CURRENT_SESSION_FILE.parent / f"session_{ts}.json"

            if not dry_run:
                shutil.copy2(CURRENT_SESSION_FILE, archive_path)
                CURRENT_SESSION_FILE.write_text("[]", encoding="utf-8")

            stats["rotated"] = True
            _log(f"Session: rotated {len(data)} entries ({size_mb}MB) -> {archive_path.name}", dry_run)
    except Exception as e:
        _log(f"Session rotation error: {e}")
    return stats


def _copy_and_truncate(src: Path, dst: Path):
    """Windows-safe rotation: copy to archive, then truncate original in-place.

    On Windows, open files can't be moved/renamed. Copy+truncate avoids this.
    """
    if dst.exists():
        dst.unlink()
    shutil.copy2(str(src), str(dst))
    # Truncate original in-place (works even if another process holds it open)
    with open(src, "w", encoding="utf-8") as f:
        pass  # empties the file


def rotate_capture_logs(dry_run=False) -> list:
    """DISABLED: Capture logs are append-only golden source of truth.
    All daemons (bridge, extraction, consolidation) depend on continuous capture logs.
    Rotation destroys data, breaks watermarks, and kills the knowledge pipeline.
    This function is kept as a no-op for backward compatibility."""
    return []  # NEVER rotate capture logs


def rotate_err_logs(dry_run=False) -> list:
    """Rotate orchestrator error logs exceeding ERR_LOG_MAX_BYTES."""
    results = []
    for err_file in sorted(STATE_DIR.glob("*.err")):
        size = err_file.stat().st_size
        size_mb = round(size / 1048576, 1)
        if size < ERR_LOG_MAX_BYTES:
            continue

        rotated_path = err_file.with_suffix(".err.1")
        if not dry_run:
            try:
                _copy_and_truncate(err_file, rotated_path)
            except Exception as e:
                _log(f"ErrLog rotation failed for {err_file.name}: {e}")
                continue

        _log(f"ErrLog: {err_file.name} ({size_mb}MB) -> {rotated_path.name}", dry_run)
        results.append({"file": err_file.name, "size_mb": size_mb, "rotated": True})
    return results


def cleanup_archive(dry_run=False) -> dict:
    """Keep only ARCHIVE_KEEP_PER_TYPE most recent files per type prefix."""
    stats = {"deleted": 0, "freed_mb": 0}
    if not ARCHIVE_DIR.exists():
        return stats

    # Group archive files by prefix (e.g., "auto_lessons", "auto_fails")
    groups: dict[str, list] = {}
    for f in sorted(ARCHIVE_DIR.iterdir()):
        if not f.is_file():
            continue
        # Extract base name: "auto_lessons_20260221_023323.md" -> "auto_lessons"
        parts = f.stem.split("_")
        # Find the timestamp boundary (8-digit date)
        prefix_parts = []
        for p in parts:
            if len(p) == 8 and p.isdigit():
                break
            prefix_parts.append(p)
        prefix = "_".join(prefix_parts) if prefix_parts else f.stem
        groups.setdefault(prefix, []).append(f)

    for prefix, files in groups.items():
        # Sort by mtime (newest first), keep ARCHIVE_KEEP_PER_TYPE
        files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
        to_delete = files[ARCHIVE_KEEP_PER_TYPE:]
        for f in to_delete:
            size_mb = round(f.stat().st_size / 1048576, 2)
            if not dry_run:
                f.unlink()
            stats["deleted"] += 1
            stats["freed_mb"] += size_mb
            _log(f"Archive: delete {f.name} ({size_mb}MB)", dry_run)

    stats["freed_mb"] = round(stats["freed_mb"], 1)
    return stats


def _cleanup_old_rotated_sessions(dry_run=False) -> dict:
    """Clean up old rotated session files, keep most recent 5."""
    stats = {"deleted": 0, "freed_mb": 0}
    sessions_dir = CURRENT_SESSION_FILE.parent
    rotated = sorted(
        [f for f in sessions_dir.glob("session_*.json") if f.is_file()],
        key=lambda x: x.stat().st_mtime, reverse=True
    )
    to_delete = rotated[5:]
    for f in to_delete:
        size_mb = round(f.stat().st_size / 1048576, 2)
        if not dry_run:
            f.unlink()
        stats["deleted"] += 1
        stats["freed_mb"] += size_mb
        _log(f"Session archive: delete {f.name} ({size_mb}MB)", dry_run)
    stats["freed_mb"] = round(stats["freed_mb"], 1)
    return stats


def run_gc(dry_run=False) -> dict:
    """Run all GC tasks. Returns summary report."""
    start = time.time()
    report = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "dry_run": dry_run,
    }

    _log(f"Starting {'dry run' if dry_run else 'garbage collection'}...")

    report["session"] = rotate_session(dry_run)
    report["capture_logs"] = []  # disabled: capture logs must never be rotated/truncated
    report["err_logs"] = rotate_err_logs(dry_run)
    report["archive"] = cleanup_archive(dry_run)
    report["old_sessions"] = _cleanup_old_rotated_sessions(dry_run)

    # Hot cache trim (Phase 3 — remove expired entries)
    try:
        from pulse.brain.memory.hot_cache import trim_hot
        removed = trim_hot() if not dry_run else 0
        report["hot_cache_trimmed"] = removed
        if removed:
            _log(f"Hot cache: trimmed {removed} expired entries", dry_run)
    except Exception:
        pass

    elapsed = round(time.time() - start, 1)
    total_freed = (
        sum(r["size_mb"] for r in report["capture_logs"])
        + sum(r["size_mb"] for r in report["err_logs"])
        + report["archive"]["freed_mb"]
        + report["old_sessions"]["freed_mb"]
    )
    if report["session"].get("rotated"):
        total_freed += report["session"]["size_mb"]

    report["elapsed_s"] = elapsed
    report["total_freed_mb"] = round(total_freed, 1)

    _log(f"Done in {elapsed}s. Freed ~{total_freed:.1f}MB", dry_run)

    # Persist report (not in dry run)
    if not dry_run:
        report_path = STATE_DIR / "health" / "gc_report.json"
        report_path.parent.mkdir(parents=True, exist_ok=True)
        report_path.write_text(
            json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
        )

    return report


if __name__ == "__main__":
    dry = "--dry-run" in sys.argv
    report = run_gc(dry_run=dry)
    print(json.dumps(report, indent=2))
