"""
Phase 77: Enhanced Self-Healing Defibrillator (V2)

Auto-detects and fixes platform issues: orphan locks, stale sessions, stuck
tasks, dead channels, missing indices, and disk-space anomalies.  All healing
actions are logged to ``state/broker/healing/history.jsonl``.

Storage:
  state/broker/healing/history.jsonl
"""

import json
import os
import threading
import time
import shutil
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional

from .server import BROKER_DIR, _append_jsonl_sync, _read_jsonl_sync

HEALING_DIR = BROKER_DIR / "healing"
SESSIONS_DIR = BROKER_DIR / "sessions"
TASKS_DIR = BROKER_DIR / "tasks"
LOCKS_DIR = BROKER_DIR / "locks"
CHANNELS_DIR = BROKER_DIR / "channels"


def _ensure_dir() -> None:
    HEALING_DIR.mkdir(parents=True, exist_ok=True)


def _history_path() -> Path:
    return HEALING_DIR / "history.jsonl"


def _log_action(action: dict) -> None:
    _ensure_dir()
    action.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
    _append_jsonl_sync(_history_path(), action)


# ── Diagnostics ──────────────────────────────────────────────────────────


def _check_orphan_locks() -> list[dict]:
    """Find lock files whose owning session no longer exists."""
    issues = []
    if not LOCKS_DIR.exists():
        return issues

    active_sessions: set[str] = set()
    if SESSIONS_DIR.exists():
        for entry in os.scandir(SESSIONS_DIR):
            if entry.is_file() and entry.name.endswith(".json"):
                active_sessions.add(entry.name[:-5])

    for entry in os.scandir(LOCKS_DIR):
        if not (entry.is_file() and entry.name.endswith(".lock")):
            continue
        try:
            data = json.loads(Path(entry.path).read_text("utf-8"))
            holder = data.get("session_id", "")
            if holder and holder not in active_sessions:
                issues.append({
                    "type": "orphan_lock",
                    "lock_file": entry.path,
                    "session_id": holder,
                    "file": data.get("filepath", entry.name),
                })
        except (json.JSONDecodeError, OSError):
            continue
    return issues


def _check_stale_sessions(max_age_hours: float = 2) -> list[dict]:
    """Find session files with no heartbeat within *max_age_hours*."""
    issues = []
    if not SESSIONS_DIR.exists():
        return issues

    cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
    for entry in os.scandir(SESSIONS_DIR):
        if not (entry.is_file() and entry.name.endswith(".json")):
            continue
        try:
            data = json.loads(Path(entry.path).read_text("utf-8"))
            hb_str = data.get("heartbeat") or data.get("started_at", "")
            if not hb_str:
                # Fall back to mtime
                mtime = datetime.fromtimestamp(os.path.getmtime(entry.path), tz=timezone.utc)
                if mtime < cutoff:
                    issues.append({
                        "type": "stale_session",
                        "session_file": entry.path,
                        "session_id": data.get("session_id", entry.name[:-5]),
                        "name": data.get("name", "unknown"),
                    })
                continue

            hb = datetime.fromisoformat(hb_str)
            if hb.tzinfo is None:
                hb = hb.replace(tzinfo=timezone.utc)
            if hb < cutoff:
                issues.append({
                    "type": "stale_session",
                    "session_file": entry.path,
                    "session_id": data.get("session_id", entry.name[:-5]),
                    "name": data.get("name", "unknown"),
                })
        except (json.JSONDecodeError, ValueError, OSError):
            continue
    return issues


def _check_stuck_tasks(max_age_minutes: float = 60) -> list[dict]:
    """Find tasks stuck in 'accepted' state beyond *max_age_minutes*."""
    issues = []
    if not TASKS_DIR.exists():
        return issues

    cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age_minutes)
    for entry in os.scandir(TASKS_DIR):
        if not (entry.is_file() and entry.name.endswith(".json")):
            continue
        try:
            data = json.loads(Path(entry.path).read_text("utf-8"))
            if data.get("status") != "accepted":
                continue
            accepted_str = data.get("accepted_at")
            if not accepted_str:
                continue
            accepted_at = datetime.fromisoformat(accepted_str)
            if accepted_at.tzinfo is None:
                accepted_at = accepted_at.replace(tzinfo=timezone.utc)
            if accepted_at < cutoff:
                issues.append({
                    "type": "stuck_task",
                    "task_file": entry.path,
                    "task_id": data.get("task_id", entry.name[:-5]),
                    "title": data.get("title", ""),
                    "assignee": data.get("assignee_name", "unknown"),
                })
        except (json.JSONDecodeError, ValueError, OSError):
            continue
    return issues


def _check_dead_channels(max_inactive_days: int = 7) -> list[dict]:
    """Find channels with no messages in *max_inactive_days*."""
    issues = []
    if not CHANNELS_DIR.exists():
        return issues

    cutoff_ts = time.time() - (max_inactive_days * 86400)
    for entry in os.scandir(CHANNELS_DIR):
        if not (entry.is_file() and entry.name.endswith(".jsonl")):
            continue
        try:
            mtime = os.path.getmtime(entry.path)
            if mtime < cutoff_ts:
                issues.append({
                    "type": "dead_channel",
                    "channel_file": entry.path,
                    "channel": entry.name[:-6],
                    "last_modified": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(),
                })
        except OSError:
            continue
    return issues


def _check_missing_indices() -> list[dict]:
    """Check that expected index directories exist."""
    issues = []
    expected_dirs = ["channels", "sessions", "tasks"]
    for dirname in expected_dirs:
        target = BROKER_DIR / dirname
        if not target.exists():
            issues.append({
                "type": "missing_index",
                "directory": str(target),
                "name": dirname,
            })
    return issues


def _check_disk_space() -> list[dict]:
    """Warn if free disk space is below 100 MB."""
    issues = []
    try:
        usage = shutil.disk_usage(str(BROKER_DIR))
        free_mb = usage.free / (1024 * 1024)
        if free_mb < 100:
            issues.append({
                "type": "low_disk_space",
                "free_mb": round(free_mb, 2),
                "total_mb": round(usage.total / (1024 * 1024), 2),
            })
    except OSError:
        pass
    return issues


def run_diagnostics() -> dict:
    """Run all diagnostic checks and return a structured report."""
    orphan_locks = _check_orphan_locks()
    stale_sessions = _check_stale_sessions()
    stuck_tasks = _check_stuck_tasks()
    dead_channels = _check_dead_channels()
    missing_indices = _check_missing_indices()
    disk_space = _check_disk_space()

    all_issues = orphan_locks + stale_sessions + stuck_tasks + dead_channels + missing_indices + disk_space
    return {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "total_issues": len(all_issues),
        "orphan_locks": orphan_locks,
        "stale_sessions": stale_sessions,
        "stuck_tasks": stuck_tasks,
        "dead_channels": dead_channels,
        "missing_indices": missing_indices,
        "disk_space": disk_space,
        "healthy": len(all_issues) == 0,
    }


# ── Healing Actions ──────────────────────────────────────────────────────


def heal_orphan_locks() -> list[dict]:
    """Remove lock files for sessions that no longer exist."""
    healed = []
    for issue in _check_orphan_locks():
        try:
            Path(issue["lock_file"]).unlink(missing_ok=True)
            action = {
                "action": "removed_orphan_lock",
                "lock_file": issue["lock_file"],
                "session_id": issue["session_id"],
            }
            _log_action(action)
            healed.append(action)
        except OSError:
            continue
    return healed


def heal_stale_sessions(max_age_hours: float = 2) -> list[dict]:
    """Clean up session files with no heartbeat."""
    healed = []
    for issue in _check_stale_sessions(max_age_hours):
        try:
            Path(issue["session_file"]).unlink(missing_ok=True)
            action = {
                "action": "removed_stale_session",
                "session_file": issue["session_file"],
                "session_id": issue["session_id"],
                "name": issue["name"],
            }
            _log_action(action)
            healed.append(action)
        except OSError:
            continue
    return healed


def heal_stuck_tasks(max_age_minutes: float = 60) -> list[dict]:
    """Requeue tasks stuck in 'accepted' state."""
    healed = []
    for issue in _check_stuck_tasks(max_age_minutes):
        try:
            path = Path(issue["task_file"])
            data = json.loads(path.read_text("utf-8"))
            old_assignee = data.get("assignee_name", "unknown")
            data["status"] = "pending"
            data["assignee_session"] = None
            data["assignee_name"] = None
            data["accepted_at"] = None
            data["requeued_at"] = datetime.now(timezone.utc).isoformat()
            data["requeue_reason"] = f"Stuck > {max_age_minutes}min (was: {old_assignee})"
            path.write_text(json.dumps(data, indent=2), encoding="utf-8")

            action = {
                "action": "requeued_stuck_task",
                "task_id": issue["task_id"],
                "title": issue["title"],
                "old_assignee": old_assignee,
            }
            _log_action(action)
            healed.append(action)
        except (json.JSONDecodeError, OSError):
            continue
    return healed


def heal_dead_channels(max_inactive_days: int = 7) -> list[dict]:
    """Archive channels with no messages in *max_inactive_days*."""
    healed = []
    archive_dir = CHANNELS_DIR / "_archive" if CHANNELS_DIR.exists() else BROKER_DIR / "channels" / "_archive"
    for issue in _check_dead_channels(max_inactive_days):
        try:
            src = Path(issue["channel_file"])
            archive_dir.mkdir(parents=True, exist_ok=True)
            dst = archive_dir / src.name
            src.rename(dst)
            action = {
                "action": "archived_dead_channel",
                "channel": issue["channel"],
                "archived_to": str(dst),
            }
            _log_action(action)
            healed.append(action)
        except OSError:
            continue
    return healed


def auto_heal(issues: Optional[list[dict]] = None) -> dict:
    """Automatically fix detected issues.

    If *issues* is None, run diagnostics first.  Returns what was fixed.
    """
    if issues is None:
        diag = run_diagnostics()
        issues = (
            diag["orphan_locks"]
            + diag["stale_sessions"]
            + diag["stuck_tasks"]
            + diag["dead_channels"]
            + diag["missing_indices"]
        )

    fixed: list[dict] = []

    # Group by type and heal
    orphans = [i for i in issues if i.get("type") == "orphan_lock"]
    stale = [i for i in issues if i.get("type") == "stale_session"]
    stuck = [i for i in issues if i.get("type") == "stuck_task"]
    dead = [i for i in issues if i.get("type") == "dead_channel"]
    missing = [i for i in issues if i.get("type") == "missing_index"]

    if orphans:
        fixed.extend(heal_orphan_locks())
    if stale:
        fixed.extend(heal_stale_sessions())
    if stuck:
        fixed.extend(heal_stuck_tasks())
    if dead:
        fixed.extend(heal_dead_channels())

    for idx in missing:
        try:
            Path(idx["directory"]).mkdir(parents=True, exist_ok=True)
            action = {
                "action": "created_missing_index",
                "directory": idx["directory"],
                "name": idx["name"],
            }
            _log_action(action)
            fixed.append(action)
        except OSError:
            continue

    return {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "issues_detected": len(issues),
        "fixes_applied": len(fixed),
        "fixes": fixed,
    }


# ── Scheduled Healing ────────────────────────────────────────────────────

_healing_timer: Optional[threading.Timer] = None


def schedule_healing(interval_minutes: float = 30) -> dict:
    """Run auto-heal on a repeating timer (background thread).

    Returns confirmation.  Call ``cancel_healing()`` to stop.
    """
    global _healing_timer

    def _run_and_reschedule():
        global _healing_timer
        auto_heal()
        _healing_timer = threading.Timer(interval_minutes * 60, _run_and_reschedule)
        _healing_timer.daemon = True
        _healing_timer.start()

    cancel_healing()  # stop any existing timer
    _healing_timer = threading.Timer(interval_minutes * 60, _run_and_reschedule)
    _healing_timer.daemon = True
    _healing_timer.start()
    return {"scheduled": True, "interval_minutes": interval_minutes}


def cancel_healing() -> dict:
    """Cancel a previously scheduled healing timer."""
    global _healing_timer
    if _healing_timer is not None:
        _healing_timer.cancel()
        _healing_timer = None
        return {"cancelled": True}
    return {"cancelled": False, "note": "No timer was running"}


# ── History ──────────────────────────────────────────────────────────────


def get_healing_history(limit: int = 20) -> list[dict]:
    """Return the most recent healing actions."""
    entries = _read_jsonl_sync(_history_path())
    return entries[-limit:]
