"""
Phase 40: Continuous Health Monitoring & Alerting System.

Provides continuous health monitoring with alerting, SLA tracking, and
historical trend analysis for the Brynq platform. Builds on self_healing.py
(Phase 16) and hardening.py (Phase 30) to deliver production-grade
observability.

Features:
  - HEALTH CHECKS: Full subsystem health with scored reports
  - CONTINUOUS MONITOR: Background thread polling on a configurable interval
  - ALERT RULES: Configurable conditions with severity and cooldown
  - ALERT HISTORY: Persistent alert log with acknowledgment workflow
  - SLA TRACKING: Uptime, task completion rate, response time metrics
  - HEALTH HISTORY: Trend analysis over historical check data
"""

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

from brynq.server import (
    BROKER_DIR,
    CHANNELS_DIR,
    SESSIONS_DIR,
    _append_jsonl_sync,
    _read_jsonl_sync,
)

# ── Storage ───────────────────────────────────────────────────────────────

HEALTH_DIR = BROKER_DIR / "health"
CHECKS_FILE = HEALTH_DIR / "checks.jsonl"
ALERTS_FILE = HEALTH_DIR / "alerts.jsonl"
SLA_FILE = HEALTH_DIR / "sla.jsonl"

TASKS_DIR = BROKER_DIR / "tasks"
API_KEYS_DIR = BROKER_DIR / "api_keys"
PROFILES_DIR = BROKER_DIR / "profiles"


def _ensure_health_dir() -> None:
    HEALTH_DIR.mkdir(parents=True, exist_ok=True)


# ── 1. Health Check ──────────────────────────────────────────────────────


def run_health_check() -> dict:
    """Run a full health check across all subsystems and return a scored report.

    Returns:
        {
            "status": "healthy" | "degraded" | "unhealthy",
            "score": 0-100,
            "checks": { subsystem: { ... } },
            "timestamp": "ISO-8601"
        }
    """
    _ensure_health_dir()
    checks: dict[str, dict] = {}
    score = 100
    now = datetime.now(timezone.utc)

    # --- broker: channels dir exists and is writable ---
    broker_check: dict[str, Any] = {"name": "broker"}
    try:
        if CHANNELS_DIR.exists():
            test_file = CHANNELS_DIR / ".health_probe"
            test_file.write_text("ok", encoding="utf-8")
            test_file.unlink(missing_ok=True)
            broker_check["status"] = "healthy"
            broker_check["detail"] = "Channels directory writable"
        else:
            broker_check["status"] = "unhealthy"
            broker_check["detail"] = "Channels directory missing"
            score -= 25
    except OSError as exc:
        broker_check["status"] = "unhealthy"
        broker_check["detail"] = f"Write test failed: {exc}"
        score -= 25
    checks["broker"] = broker_check

    # --- sessions: count active, detect stale (>1hr no heartbeat) ---
    sessions_check: dict[str, Any] = {"name": "sessions"}
    total_sessions = 0
    stale_sessions = 0
    try:
        if SESSIONS_DIR.exists():
            for entry in os.scandir(str(SESSIONS_DIR)):
                if not (entry.is_file() and entry.name.endswith(".json")):
                    continue
                total_sessions += 1
                try:
                    data = json.loads(Path(entry.path).read_text("utf-8"))
                    hb_str = data.get("heartbeat", "")
                    if hb_str:
                        hb = datetime.fromisoformat(hb_str)
                        age = (now - hb).total_seconds()
                        if age > 3600:
                            stale_sessions += 1
                except (json.JSONDecodeError, ValueError, OSError):
                    stale_sessions += 1
        sessions_check["total"] = total_sessions
        sessions_check["stale"] = stale_sessions
        if stale_sessions > 3:
            sessions_check["status"] = "degraded"
            score -= 10
        else:
            sessions_check["status"] = "healthy"
    except OSError:
        sessions_check["status"] = "unhealthy"
        score -= 15
    checks["sessions"] = sessions_check

    # --- tasks: pending/completed/failed counts, stuck detection ---
    tasks_check: dict[str, Any] = {"name": "tasks"}
    pending = 0
    completed = 0
    failed = 0
    stuck = 0
    try:
        if TASKS_DIR.exists():
            for entry in os.scandir(str(TASKS_DIR)):
                if not (entry.is_file() and entry.name.endswith(".json")):
                    continue
                try:
                    data = json.loads(Path(entry.path).read_text("utf-8"))
                    status = data.get("status", "")
                    if status == "pending":
                        pending += 1
                    elif status == "completed":
                        completed += 1
                    elif status == "failed":
                        failed += 1
                    elif status == "accepted":
                        accepted_at_str = data.get("accepted_at")
                        if accepted_at_str:
                            accepted_at = datetime.fromisoformat(accepted_at_str)
                            if (now - accepted_at).total_seconds() > 1800:
                                stuck += 1
                except (json.JSONDecodeError, ValueError, OSError):
                    continue
        tasks_check["pending"] = pending
        tasks_check["completed"] = completed
        tasks_check["failed"] = failed
        tasks_check["stuck"] = stuck
        if stuck > 5:
            tasks_check["status"] = "degraded"
            score -= 15
        elif stuck > 0:
            tasks_check["status"] = "degraded"
            score -= 5
        else:
            tasks_check["status"] = "healthy"
    except OSError:
        tasks_check["status"] = "unhealthy"
        score -= 15
    checks["tasks"] = tasks_check

    # --- disk: available space and total log size ---
    disk_check: dict[str, Any] = {"name": "disk"}
    try:
        usage = shutil.disk_usage(str(BROKER_DIR))
        free_mb = usage.free / (1024 * 1024)
        disk_check["free_mb"] = round(free_mb, 1)
        disk_check["total_mb"] = round(usage.total / (1024 * 1024), 1)

        # Total log size across channels
        total_log_bytes = 0
        if CHANNELS_DIR.exists():
            for entry in os.scandir(str(CHANNELS_DIR)):
                if entry.is_file():
                    total_log_bytes += entry.stat().st_size
        disk_check["total_log_size_mb"] = round(total_log_bytes / (1024 * 1024), 2)

        if free_mb < 100:
            disk_check["status"] = "unhealthy"
            score -= 20
        elif free_mb < 500:
            disk_check["status"] = "degraded"
            score -= 10
        else:
            disk_check["status"] = "healthy"
    except OSError:
        disk_check["status"] = "unhealthy"
        score -= 20
    checks["disk"] = disk_check

    # --- api_keys: count registered keys ---
    keys_check: dict[str, Any] = {"name": "api_keys"}
    try:
        key_count = 0
        if API_KEYS_DIR.exists():
            key_count = sum(
                1 for e in os.scandir(str(API_KEYS_DIR))
                if e.is_file() and e.name.endswith(".json")
            )
        keys_check["count"] = key_count
        keys_check["status"] = "healthy"
    except OSError:
        keys_check["status"] = "degraded"
        keys_check["count"] = 0
        score -= 5
    checks["api_keys"] = keys_check

    # --- profiles: count active profiles ---
    profiles_check: dict[str, Any] = {"name": "profiles"}
    try:
        profile_count = 0
        if PROFILES_DIR.exists():
            profile_count = sum(
                1 for e in os.scandir(str(PROFILES_DIR))
                if e.is_file() and e.name.endswith(".json")
            )
        profiles_check["count"] = profile_count
        profiles_check["status"] = "healthy"
    except OSError:
        profiles_check["status"] = "degraded"
        profiles_check["count"] = 0
        score -= 5
    checks["profiles"] = profiles_check

    # --- Compute overall status ---
    score = max(0, score)
    if score >= 80:
        status = "healthy"
    elif score >= 50:
        status = "degraded"
    else:
        status = "unhealthy"

    report = {
        "status": status,
        "score": score,
        "checks": checks,
        "timestamp": now.isoformat(),
    }

    # Persist to history
    _append_jsonl_sync(CHECKS_FILE, report)

    return report


# ── 2. Continuous Monitor ────────────────────────────────────────────────

_monitor_thread: Optional[threading.Thread] = None
_monitor_stop_event = threading.Event()


def start_monitor(interval_seconds: int = 60) -> None:
    """Start the continuous health monitor in a background daemon thread."""
    global _monitor_thread

    if _monitor_thread is not None and _monitor_thread.is_alive():
        return  # Already running

    _monitor_stop_event.clear()

    def _loop() -> None:
        while not _monitor_stop_event.is_set():
            try:
                report = run_health_check()
                _evaluate_alert_rules(report)
            except Exception:
                pass
            _monitor_stop_event.wait(timeout=interval_seconds)

    _monitor_thread = threading.Thread(target=_loop, daemon=True, name="brynq-health-monitor")
    _monitor_thread.start()


def stop_monitor() -> None:
    """Stop the continuous health monitor."""
    global _monitor_thread
    _monitor_stop_event.set()
    if _monitor_thread is not None:
        _monitor_thread.join(timeout=5)
        _monitor_thread = None


def is_monitoring() -> bool:
    """Return True if the health monitor background thread is running."""
    return _monitor_thread is not None and _monitor_thread.is_alive()


# ── 3. Alert Rules ──────────────────────────────────────────────────────

_alert_rules: dict[str, dict] = {}
_alert_cooldowns: dict[str, float] = {}  # rule_name -> last_trigger_epoch


def add_alert_rule(
    name: str,
    condition_fn: Callable[[dict], bool],
    severity: str = "warning",
    cooldown_minutes: int = 5,
) -> None:
    """Register an alert rule.

    Args:
        name: Unique rule name.
        condition_fn: Function that receives a health report and returns True if alert should fire.
        severity: One of "info", "warning", "critical".
        cooldown_minutes: Minimum minutes between repeated firings of this rule.
    """
    _alert_rules[name] = {
        "name": name,
        "condition_fn": condition_fn,
        "severity": severity,
        "cooldown_minutes": cooldown_minutes,
    }


def remove_alert_rule(name: str) -> bool:
    """Remove an alert rule by name. Returns True if it existed."""
    return _alert_rules.pop(name, None) is not None


def list_alert_rules() -> list[dict]:
    """Return metadata for all registered alert rules."""
    return [
        {"name": r["name"], "severity": r["severity"], "cooldown_minutes": r["cooldown_minutes"]}
        for r in _alert_rules.values()
    ]


def _evaluate_alert_rules(report: dict) -> list[dict]:
    """Evaluate all registered alert rules against a health report.

    Returns list of alerts that were triggered.
    """
    triggered: list[dict] = []
    now = time.time()

    for name, rule in _alert_rules.items():
        # Check cooldown
        last = _alert_cooldowns.get(name, 0)
        if now - last < rule["cooldown_minutes"] * 60:
            continue

        try:
            should_fire = rule["condition_fn"](report)
        except Exception:
            continue

        if should_fire:
            _alert_cooldowns[name] = now
            alert = _create_alert(
                rule_name=name,
                severity=rule["severity"],
                message=f"Alert rule '{name}' triggered",
                report_snapshot=report,
            )
            triggered.append(alert)

    return triggered


def _create_alert(
    rule_name: str,
    severity: str,
    message: str,
    report_snapshot: Optional[dict] = None,
) -> dict:
    """Create and persist an alert record."""
    _ensure_health_dir()
    alert = {
        "alert_id": uuid.uuid4().hex[:12],
        "rule_name": rule_name,
        "severity": severity,
        "message": message,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "acknowledged": False,
        "acknowledged_at": None,
    }
    if report_snapshot:
        alert["score"] = report_snapshot.get("score")
        alert["status"] = report_snapshot.get("status")

    _append_jsonl_sync(ALERTS_FILE, alert)

    # Also post to broker channel for visibility
    try:
        channel_file = CHANNELS_DIR / "_health_alerts.jsonl"
        _append_jsonl_sync(channel_file, {
            "id": alert["alert_id"],
            "timestamp": alert["timestamp"],
            "session_id": "health-monitor",
            "session_name": "Brynq-HealthMonitor",
            "platform": "system",
            "channel": "_health_alerts",
            "msg_type": "alert",
            "message": f"[{severity.upper()}] {message}",
        })
    except Exception:
        pass

    return alert


# ── Built-in Alert Rules ─────────────────────────────────────────────────


def _rule_disk_space_low(report: dict) -> bool:
    """Trigger when free disk space < 500MB."""
    disk = report.get("checks", {}).get("disk", {})
    free = disk.get("free_mb", 999999)
    return free < 500


def _rule_stuck_tasks(report: dict) -> bool:
    """Trigger when >5 tasks are stuck (accepted but not completed in 30min)."""
    tasks = report.get("checks", {}).get("tasks", {})
    return tasks.get("stuck", 0) > 5


def _rule_stale_sessions(report: dict) -> bool:
    """Trigger when >3 sessions have no heartbeat in 1hr."""
    sessions = report.get("checks", {}).get("sessions", {})
    return sessions.get("stale", 0) > 3


def _rule_high_error_rate(report: dict) -> bool:
    """Trigger when >20% of tasks in the report are failed."""
    tasks = report.get("checks", {}).get("tasks", {})
    completed = tasks.get("completed", 0)
    failed = tasks.get("failed", 0)
    total = completed + failed
    if total == 0:
        return False
    return (failed / total) > 0.20


def register_builtin_rules() -> None:
    """Register the four built-in alert rules."""
    add_alert_rule("disk_space_low", _rule_disk_space_low, severity="critical", cooldown_minutes=5)
    add_alert_rule("stuck_tasks", _rule_stuck_tasks, severity="warning", cooldown_minutes=10)
    add_alert_rule("stale_sessions", _rule_stale_sessions, severity="warning", cooldown_minutes=10)
    add_alert_rule("high_error_rate", _rule_high_error_rate, severity="critical", cooldown_minutes=5)


# ── 4. Alert History ────────────────────────────────────────────────────


def get_alerts(
    severity: Optional[str] = None,
    since: Optional[str] = None,
    limit: int = 50,
) -> list[dict]:
    """Return past alerts, optionally filtered by severity and time.

    Args:
        severity: Filter to this severity level (info, warning, critical).
        since: ISO-8601 timestamp; only return alerts after this time.
        limit: Maximum alerts to return (most recent first).
    """
    _ensure_health_dir()
    all_alerts = _read_jsonl_sync(ALERTS_FILE)
    result = []

    since_dt = None
    if since:
        try:
            since_dt = datetime.fromisoformat(since)
        except ValueError:
            pass

    for alert in all_alerts:
        if severity and alert.get("severity") != severity:
            continue
        if since_dt:
            try:
                ts = datetime.fromisoformat(alert.get("timestamp", ""))
                if ts < since_dt:
                    continue
            except (ValueError, TypeError):
                continue
        result.append(alert)

    # Most recent first
    result.reverse()
    return result[:limit]


def acknowledge_alert(alert_id: str) -> bool:
    """Mark an alert as acknowledged. Returns True if found and updated."""
    _ensure_health_dir()
    if not ALERTS_FILE.exists():
        return False

    all_alerts = _read_jsonl_sync(ALERTS_FILE)
    found = False

    for alert in all_alerts:
        if alert.get("alert_id") == alert_id:
            alert["acknowledged"] = True
            alert["acknowledged_at"] = datetime.now(timezone.utc).isoformat()
            found = True

    if found:
        # Rewrite file
        ALERTS_FILE.write_text("", encoding="utf-8")
        for alert in all_alerts:
            _append_jsonl_sync(ALERTS_FILE, alert)

    return found


def get_active_alerts() -> list[dict]:
    """Return all unacknowledged alerts (most recent first)."""
    _ensure_health_dir()
    all_alerts = _read_jsonl_sync(ALERTS_FILE)
    active = [a for a in all_alerts if not a.get("acknowledged", False)]
    active.reverse()
    return active


# ── 5. SLA Tracking ─────────────────────────────────────────────────────


def record_sla_event(metric: str, value: float, target: float) -> dict:
    """Record an SLA measurement.

    Args:
        metric: Name of the SLA metric (e.g. "uptime", "task_completion_rate", "avg_response_time").
        value: The measured value.
        target: The SLA target value.

    Returns:
        The recorded event dict.
    """
    _ensure_health_dir()
    event = {
        "metric": metric,
        "value": value,
        "target": target,
        "met": value >= target,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }
    _append_jsonl_sync(SLA_FILE, event)
    return event


def get_sla_report(hours: int = 24) -> dict:
    """Return SLA compliance per metric for the given time window.

    Returns:
        {
            "period_hours": 24,
            "metrics": {
                "uptime": {"events": N, "met": M, "compliance_pct": X, "avg_value": Y, "target": Z},
                ...
            }
        }
    """
    _ensure_health_dir()
    all_events = _read_jsonl_sync(SLA_FILE)
    cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
    metrics: dict[str, dict] = {}

    for event in all_events:
        try:
            ts = datetime.fromisoformat(event["timestamp"])
            if ts < cutoff:
                continue
        except (ValueError, KeyError):
            continue

        name = event.get("metric", "unknown")
        if name not in metrics:
            metrics[name] = {"events": 0, "met": 0, "total_value": 0.0, "target": event.get("target", 0)}

        metrics[name]["events"] += 1
        metrics[name]["total_value"] += event.get("value", 0)
        if event.get("met", False):
            metrics[name]["met"] += 1

    report_metrics: dict[str, dict] = {}
    for name, data in metrics.items():
        total = data["events"]
        report_metrics[name] = {
            "events": total,
            "met": data["met"],
            "compliance_pct": round((data["met"] / total * 100) if total > 0 else 0, 1),
            "avg_value": round(data["total_value"] / total, 3) if total > 0 else 0,
            "target": data["target"],
        }

    return {
        "period_hours": hours,
        "metrics": report_metrics,
    }


# ── 6. Health History ───────────────────────────────────────────────────


def get_health_history(hours: int = 24, limit: int = 100) -> list[dict]:
    """Return historical health check results for trend analysis.

    Returns most recent results first, limited to the given time window and count.
    """
    _ensure_health_dir()
    all_checks = _read_jsonl_sync(CHECKS_FILE)
    cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
    filtered = []

    for check in all_checks:
        try:
            ts = datetime.fromisoformat(check.get("timestamp", ""))
            if ts >= cutoff:
                filtered.append(check)
        except (ValueError, TypeError):
            continue

    filtered.reverse()
    return filtered[:limit]


def get_health_trend() -> str:
    """Analyze recent health scores and return a trend label.

    Returns:
        "improving" — scores trending upward
        "stable"    — scores roughly flat
        "degrading" — scores trending downward
        "unknown"   — not enough data
    """
    _ensure_health_dir()
    all_checks = _read_jsonl_sync(CHECKS_FILE)

    if len(all_checks) < 3:
        return "unknown"

    # Use the last 10 checks (or fewer if not available)
    recent = all_checks[-10:]
    scores = [c.get("score", 0) for c in recent]

    # Split into first half and second half
    mid = len(scores) // 2
    first_half_avg = sum(scores[:mid]) / max(mid, 1)
    second_half_avg = sum(scores[mid:]) / max(len(scores) - mid, 1)

    diff = second_half_avg - first_half_avg
    if diff > 5:
        return "improving"
    elif diff < -5:
        return "degrading"
    else:
        return "stable"


# ── Module init ──────────────────────────────────────────────────────────

if __name__ == "__main__":
    register_builtin_rules()
    report = run_health_check()
    print(json.dumps(report, indent=2))
