#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""D13: Homeostatic Scheduler — Adaptive Daemon Metabolism.
SENSE → COMPUTE → ADJUST → PERSIST. Every 60s via orchestrator.
Dynamically compresses/stretches ALL scheduler intervals based on swarm stress.
"""
from __future__ import annotations

import io
import json
import os
import sys
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path

if os.name == "nt" and __name__ == "__main__":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")

from pulse.core.paths import get_root_dir
from pulse.core.brain_utils import STATE_DIR, TASK_BOARD_FILE, now_iso, is_process_running
from pulse.core.brain_io import _acquire, save_json

ROOT = get_root_dir()
STATE_FILE = STATE_DIR / "homeostatic_state.json"
OVERRIDE_FILE = STATE_DIR / "homeostatic_overrides.json"
HEARTBEAT_DIR = STATE_DIR / "heartbeats"
WORKER_PIDS_FILE = STATE_DIR / "worker_pids.json"
SUPERVISOR_ALERTS_FILE = STATE_DIR / "supervisor_alerts.json"
DAEMON_PIDS_FILE = STATE_DIR / "daemon_pids.json"

# Stress weights
W_HEARTBEAT = 0.30
W_RESPAWN = 0.25
W_ORPHAN = 0.25
W_PILEUP = 0.20
HEARTBEAT_STALE = 90       # seconds — matches worker_supervisor
ALERT_WINDOW = 3600         # 1 hour for respawn counting
HISTORY_CAP = 100           # rolling stress history

# Daemon priority tiers — which daemons matter most under stress
DAEMON_PRIORITY = {
    "prediction_scheduler": "critical", "swarm_reflex_scheduler": "critical",
    "causal_recovery_scheduler": "critical",
    "consolidation_scheduler": "high", "gate_feedback_scheduler": "high",
    "hebbian_balancer_scheduler": "high", "auto_capture_scheduler": "high",
    "pathweaver_scheduler": "medium", "constraint_engine_scheduler": "medium",
    "ghost_resonance_scheduler": "medium", "brain_healer_scheduler": "medium",
    "anti_pattern_scheduler": "medium", "healer_scheduler": "medium",
    "sleep_cycle_scheduler": "low", "epistemic_forager_scheduler": "low",
    "skill_synthesizer_scheduler": "low", "profile_distiller_scheduler": "low",
    "prune_scheduler": "low", "schema_daemon_scheduler": "low",
    "procedure_daemon_scheduler": "low", "intent_daemon_scheduler": "low",
    "synthesize_scheduler": "low", "private_synthesis_scheduler": "low",
}

# Default intervals (must mirror orchestrator_schedulers.py)
DEFAULT_INTERVALS = {
    "prediction_scheduler": 300, "swarm_reflex_scheduler": 300,
    "causal_recovery_scheduler": 1800, "consolidation_scheduler": 21600,
    "gate_feedback_scheduler": 14400, "hebbian_balancer_scheduler": 900,
    "auto_capture_scheduler": 3600, "pathweaver_scheduler": 43200,
    "constraint_engine_scheduler": 21600, "ghost_resonance_scheduler": 3600,
    "brain_healer_scheduler": 14400, "anti_pattern_scheduler": 21600,
    "sleep_cycle_scheduler": 86400, "epistemic_forager_scheduler": 86400,
    "skill_synthesizer_scheduler": 86400, "profile_distiller_scheduler": 43200,
    "prune_scheduler": 86400, "schema_daemon_scheduler": 43200,
    "procedure_daemon_scheduler": 43200, "intent_daemon_scheduler": 43200,
    "synthesize_scheduler": 43200, "private_synthesis_scheduler": 86400,
    "healer_scheduler": 43200,
}

# Safety floors — never fire faster than this (seconds)
MIN_INTERVALS = {
    "prediction_scheduler": 60, "swarm_reflex_scheduler": 60,
    "causal_recovery_scheduler": 300, "consolidation_scheduler": 1800,
}
DEFAULT_MIN = 300
STRETCH_FACTOR = 2.0  # ceiling: never slower than 2x default

# Stress zones: (low, high, {priority: multiplier})
# multiplier < 1.0 = compress (faster), > 1.0 = stretch (slower), 0.0 = defer
STRESS_ZONES = [
    ("calm",     0.0, 0.2, {"critical": 1.5, "high": 1.5, "medium": 1.5, "low": 2.0}),
    ("normal",   0.2, 0.4, {"critical": 1.0, "high": 1.0, "medium": 1.0, "low": 1.0}),
    ("elevated", 0.4, 0.6, {"critical": 0.5, "high": 0.7, "medium": 1.0, "low": 1.5}),
    ("high",     0.6, 0.8, {"critical": 0.25, "high": 0.5, "medium": 0.7, "low": 3.0}),
    ("critical", 0.8, 1.01, {"critical": 0.15, "high": 0.3, "medium": 0.5, "low": 0.0}),
]

def _load_json(path: Path) -> dict | list:
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _load_state() -> dict:
    s = _load_json(STATE_FILE)
    if not isinstance(s, dict):
        s = {}
    s.setdefault("runs", 0)
    s.setdefault("stress_history", [])
    return s

def _save_state(state: dict):
    state["stress_history"] = state.get("stress_history", [])[-HISTORY_CAP:]
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    with _acquire(STATE_FILE):
        save_json(STATE_FILE, state)

def _sense_stress(verbose: bool = False) -> tuple[float, dict]:
    """Compute stress index from 4 signals. Returns (stress, breakdown)."""
    now_t, bd = time.time(), {}
    # 1. Heartbeat staleness (workers + capture daemons for interactive health)
    stale = total_hb = 0
    if HEARTBEAT_DIR.exists():
        for hb in HEARTBEAT_DIR.iterdir():
            if hb.suffix != ".json":
                continue
            total_hb += 1
            try:
                if now_t - hb.stat().st_mtime > HEARTBEAT_STALE:
                    stale += 1
            except OSError:
                stale += 1
    daemon_pids = _load_json(DAEMON_PIDS_FILE)
    if isinstance(daemon_pids, dict):
        for name, pid in daemon_pids.items():
            if "capture" in name and isinstance(pid, int):
                total_hb += 1
                if not is_process_running(pid):
                    stale += 1
    hb_score = stale / max(total_hb, 1)
    bd["heartbeat"] = {"score": round(hb_score, 2), "stale": stale, "total": total_hb}
    # 2. Respawn pressure (supervisor alerts in last hour)
    alerts = _load_json(SUPERVISOR_ALERTS_FILE)
    alert_list = alerts.get("alerts", []) if isinstance(alerts, dict) else []
    cutoff = datetime.now(timezone.utc) - timedelta(seconds=ALERT_WINDOW)
    recent = sum(1 for a in alert_list if _is_recent(a, cutoff))
    respawn_score = min(1.0, recent / 5)
    bd["respawn"] = {"score": round(respawn_score, 2), "recent": recent}
    # 3. Orphaned tasks + 4. Task pile-up (share board scan)
    board = _load_json(TASK_BOARD_FILE)
    wp = _load_json(WORKER_PIDS_FILE)
    live_ids = {r.get("id", "") for r in (wp if isinstance(wp, list) else [])
                if isinstance(r.get("pid"), int) and is_process_running(r["pid"])}
    active_tasks = orphaned = open_tasks = 0
    if isinstance(board, dict):
        for d in board.get("dispatches", []):
            for t in d.get("tasks", []):
                st = t.get("status")
                if st == "active" and t.get("claimed_by"):
                    active_tasks += 1
                    if t["claimed_by"] not in live_ids:
                        orphaned += 1
                elif st == "open":
                    open_tasks += 1
    orphan_score = orphaned / max(active_tasks, 1)
    pileup_score = min(1.0, open_tasks / max(len(live_ids) * 3, 1))
    bd["orphan"] = {"score": round(orphan_score, 2), "orphaned": orphaned, "active": active_tasks}
    bd["pileup"] = {"score": round(pileup_score, 2), "open": open_tasks, "workers": len(live_ids)}
    stress = max(0.0, min(1.0, W_HEARTBEAT * hb_score + W_RESPAWN * respawn_score +
                          W_ORPHAN * orphan_score + W_PILEUP * pileup_score))
    if verbose:
        print(f"[D13] Stress: HB={hb_score:.2f} RESP={respawn_score:.2f} "
              f"ORPH={orphan_score:.2f} PILE={pileup_score:.2f} → {stress:.2f}")
    return round(stress, 3), bd


def _is_recent(alert: dict, cutoff: datetime) -> bool:
    try:
        return datetime.fromisoformat(
            alert.get("timestamp", "").replace("Z", "+00:00")) >= cutoff
    except (ValueError, TypeError):
        return False

def _get_zone(stress: float) -> tuple[str, dict]:
    """Map stress value to zone name and priority multipliers."""
    for name, lo, hi, mults in STRESS_ZONES:
        if lo <= stress < hi:
            return name, mults
    return "normal", STRESS_ZONES[1][3]

def _compute_overrides(stress: float) -> tuple[dict, str]:
    """Compute interval overrides based on stress level."""
    zone, mults = _get_zone(stress)
    if zone == "normal":
        return {}, zone  # defaults — no overrides needed
    overrides = {}
    for sched_name, priority in DAEMON_PRIORITY.items():
        default = DEFAULT_INTERVALS.get(sched_name, 3600)
        mult = mults.get(priority, 1.0)
        if mult == 0.0:
            new_val = 86400  # deferred (24h)
        else:
            new_val = int(default * mult)
        # Safety floor
        floor = MIN_INTERVALS.get(sched_name, DEFAULT_MIN)
        new_val = max(new_val, floor)
        # Stretch ceiling
        ceiling = int(default * STRETCH_FACTOR)
        new_val = min(new_val, ceiling)
        if new_val != default:
            overrides[sched_name] = new_val
    return overrides, zone

def _write_overrides(overrides: dict, stress: float, zone: str):
    """Write override file for orchestrator_schedulers to read."""
    OVERRIDE_FILE.parent.mkdir(parents=True, exist_ok=True)
    data = {"overrides": overrides, "stress": stress, "zone": zone,
            "updated": now_iso()}
    with _acquire(OVERRIDE_FILE):
        OVERRIDE_FILE.write_text(
            json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")

def run_once(verbose: bool = False, dry_run: bool = False) -> dict:
    """Full D13 cycle: SENSE → COMPUTE → ADJUST → PERSIST."""
    t0 = time.time()
    vp = print if verbose else lambda *a: None
    stress, breakdown = _sense_stress(verbose)
    overrides, zone = _compute_overrides(stress)
    vp(f"[D13] Zone={zone} | {len(overrides)} override(s)")
    if overrides and verbose:
        for name, val in sorted(overrides.items()):
            default = DEFAULT_INTERVALS.get(name, 0)
            label = "deferred" if val >= 86400 else f"{val}s"
            vp(f"  {name:<35} {default}s → {label}")
    if dry_run:
        vp("[D13] Dry run — overrides not written")
        return {"stress": stress, "zone": zone, "overrides": len(overrides)}
    _write_overrides(overrides, stress, zone)
    state = _load_state()
    state.update(last_run=now_iso(), runs=state.get("runs", 0) + 1,
                 stress=stress, zone=zone, active_overrides=len(overrides),
                 breakdown=breakdown, last_elapsed_s=round(time.time() - t0, 2))
    state["stress_history"].append({"ts": now_iso()[:19], "s": stress, "z": zone})
    _save_state(state)
    vp(f"[D13] Done in {state['last_elapsed_s']}s")
    return {"stress": stress, "zone": zone, "overrides": len(overrides)}

def show_status():
    """Print D13 stress metrics and active overrides."""
    state = _load_state()
    last = (state.get("last_run") or "never")[:19]
    stress, zone = state.get("stress", 0.0), state.get("zone", "unknown")
    bd = state.get("breakdown", {})
    print(f"\n  D13 Homeostatic Scheduler\n  {'=' * 40}")
    for k, v in [("Runs", state.get("runs", 0)), ("Last run", last),
                 ("Stress", f"{stress:.2f} ({zone.upper()})"),
                 ("Active", f"{state.get('active_overrides', 0)} overrides")]:
        print(f"  {k + ':':<16} {v}")
    if bd:
        _SIG_FMT = {"heartbeat": lambda i: f"({i.get('stale',0)}/{i.get('total',0)} stale)",
                     "respawn": lambda i: f"({i.get('recent',0)} alerts/1h)",
                     "orphan": lambda i: f"({i.get('orphaned',0)}/{i.get('active',0)} orphaned)",
                     "pileup": lambda i: f"({i.get('open',0)} open / {i.get('workers',0)} workers)"}
        print(f"\n  Signal Breakdown:")
        for sig, fmt in _SIG_FMT.items():
            info = bd.get(sig, {})
            print(f"    {sig:<14} {info.get('score',0):.2f}  {fmt(info)}")
    ovr_data = _load_json(OVERRIDE_FILE)
    ovr = ovr_data.get("overrides", {}) if isinstance(ovr_data, dict) else {}
    if ovr:
        print(f"\n  Active Overrides:")
        for name, val in sorted(ovr.items()):
            default = DEFAULT_INTERVALS.get(name, 0)
            label = "deferred" if val >= 86400 else f"{val}s"
            print(f"    {name:<35} {default}s → {label:<10} ({DAEMON_PRIORITY.get(name, '?')})")
    print()


def get_stress_level() -> float:
    """Read current stress level from state file."""
    s = _load_json(STATE_FILE)
    return s.get("stress", 0.0) if isinstance(s, dict) else 0.0


def get_zone() -> str:
    """Read current zone name from state file."""
    s = _load_json(STATE_FILE)
    return s.get("zone", "normal") if isinstance(s, dict) else "normal"


def cmd_homeostatic(args: list):
    """CLI entry: pulse homeostatic [--dry-run] [-v] [status]."""
    if args and args[0] == "status":
        return show_status()
    verbose = "-v" in args or "--verbose" in args
    result = run_once(verbose=verbose, dry_run="--dry-run" in args)
    if not verbose:
        print(f"[D13] stress={result['stress']:.2f} zone={result['zone']} "
              f"overrides={result['overrides']}")


if __name__ == "__main__":
    cmd_homeostatic(sys.argv[1:])
