#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
# See LICENSE file for details.
"""PULSE Orchestrator (V6) — daemon lifecycle, security in orchestrator_security.py."""
import atexit
import io
import logging
import os
import signal
import subprocess
import sys
import time
from collections import defaultdict
from logging.handlers import RotatingFileHandler
from pathlib import Path

# --- Windows Unicode Fix ---
if os.name == 'nt' and __name__ == '__main__':
    if hasattr(sys.stdout, 'reconfigure'):
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    if hasattr(sys.stderr, 'reconfigure'):
        sys.stderr.reconfigure(encoding='utf-8', errors='replace')

SCRIPT_DIR = Path(__file__).resolve().parent
DAEMONS_DIR = SCRIPT_DIR.parent
ROOT_DIR = DAEMONS_DIR.parent.parent

# When run directly, ensure pulse package is importable
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))
from pulse.core.brain_utils import ensure_brain_scaffold, _acquire

from pulse.daemons.orchestrator.security import (
    verify_sealed_environment, verify_immutable_core, enforce_quarantine, load_env_file,
)
from pulse.daemons.orchestrator.schedulers import start_all_schedulers
from pulse.daemons.orchestrator.lifecycle import (
    _is_process_running, _write_daemon_alert, _kill_orphan_daemons, _save_daemon_pids,
    DAEMON_ALERTS_FILE,
)

# --- Core Paths ---
STATE_DIR = ROOT_DIR / "state"
PID_FILE = STATE_DIR / "pulse_orchestrator.pid"
DAEMON_PIDS_FILE = STATE_DIR / "daemon_pids.json"
SAFETY_CHECK = ROOT_DIR / "pulse" / "admin" / "audit" / "safety_check.py"

# --- Daemon Paths ---
DAEMON_MAP = {
    "claude_daemon": ROOT_DIR / "pulse" / "agents" / "pulse_claude.py",
    "gemini_daemon": ROOT_DIR / "pulse" / "agents" / "pulse_gemini.py",
    # grok_daemon: one-shot utility, not a daemon (977 crash loops)
    # "grok_daemon": ROOT_DIR / "pulse" / "agents" / "pulse_grok.py",
    "codex_daemon": ROOT_DIR / "pulse" / "agents" / "pulse_codex.py",
    "bridge_daemon": DAEMONS_DIR / "bridge" / "pulse_bridge.py",
    # memory_dispatcher: managed via 1h scheduler loop (capped per dispatch run)
    # "memory_dispatcher": DAEMONS_DIR / "bridge" / "memory_dispatcher.py",
    "neural_daemon": DAEMONS_DIR / "neural" / "pulse_neural.py",
    "compliance_daemon": DAEMONS_DIR / "runtime" / "compliance_validator.py",
    "dispatcher_daemon": ROOT_DIR / "pulse" / "tasks" / "pulse_dispatcher.py",
    "api_daemon": ROOT_DIR / "pulse" / "api" / "pulse_api.py",
    "worker_supervisor": DAEMONS_DIR / "runtime" / "worker_supervisor.py",
    "drift_daemon": DAEMONS_DIR / "neural" / "drift_detector.py",
    "prediction_daemon": DAEMONS_DIR / "neural" / "prediction_daemon.py",
}
DAEMON_LABELS = {
    "claude_daemon": "Claude Watcher", "gemini_daemon": "Gemini Watcher",
    "grok_daemon": "Grok Watcher", "codex_daemon": "Codex Watcher",
    "bridge_daemon": "Log-Evidence Bridge", "memory_dispatcher": "Swarm Memory Dispatcher",
    "neural_daemon": "Neural Interface", "compliance_daemon": "Compliance Validator",
    "dispatcher_daemon": "Task Dispatcher", "api_daemon": "API Gateway",
    "worker_supervisor": "Worker Supervisor",
    "drift_daemon": "Embedding Drift Detector",
    "prediction_daemon": "Prediction Oracle",
}
def _write_pid():
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    with _acquire(PID_FILE):
        PID_FILE.write_text(str(os.getpid()))
    atexit.register(_cleanup_pid)
    atexit.register(_cleanup_orphan_daemons)


def _cleanup_pid():
    try:
        with _acquire(PID_FILE):
            if PID_FILE.exists():
                PID_FILE.unlink()
    except Exception:
        pass


_orchestrator_instance = None

def _cleanup_orphan_daemons():
    if _orchestrator_instance:
        _orchestrator_instance._kill_all_daemons()

# C6: Atomic PID check to prevent TOCTOU race — only when run as __main__
if __name__ == "__main__":
    with _acquire(PID_FILE):
        if PID_FILE.exists():
            try:
                existing = int(PID_FILE.read_text())
            except Exception:
                existing = None
            if existing and _is_process_running(existing):
                print(f"[PULSE] Orchestrator already running (PID {existing}).")
                sys.exit(0)
            else:
                try:
                    PID_FILE.unlink()
                except Exception:
                    pass


class PulseOrchestrator:
    def __init__(self):
        global _orchestrator_instance
        _orchestrator_instance = self
        self.processes = {}
        self.running = True
        self.quarantine_events = defaultdict(list)
        signal.signal(signal.SIGINT, self._handle_signal)
        signal.signal(signal.SIGTERM, self._handle_signal)

    def _handle_signal(self, signum, frame):
        print("\n[PULSE] Shutting down brain daemons...")
        self.running = False
        self._kill_all_daemons()
        _cleanup_pid()
        sys.exit(0)

    def _kill_all_daemons(self):
        for name, p in self.processes.items():
            try:
                p.kill()
                p.wait(timeout=5)
            except Exception:
                pass
        try:
            DAEMON_PIDS_FILE.unlink(missing_ok=True)
        except Exception:
            pass

    def start_daemon(self, script_path, name, agent_id=None):
        print(f"[PULSE] Starting {name}...")
        try:
            p = subprocess.Popen(
                [sys.executable, str(script_path), "--verbose"],
                stdout=sys.stdout, stderr=sys.stderr)
            self.processes[agent_id or name] = p
        except Exception as e:
            print(f"[PULSE] Error starting {name}: {e}")

    def run(self):
        STATE_DIR.mkdir(parents=True, exist_ok=True)
        _kill_orphan_daemons()
        _write_pid()
        load_env_file()

        # Rotating file log — 10MB per file, keep 3 backups
        log_path = STATE_DIR / "orchestrator.log"
        _handler = RotatingFileHandler(str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8")
        _handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
        logging.getLogger("pulse.orchestrator").addHandler(_handler)
        logging.getLogger("pulse.orchestrator").setLevel(logging.INFO)

        print("=======================================================")
        print("  PULSE v6 - The Brain Orchestrator [FORTIFIED]")
        print("=======================================================")

        verify_sealed_environment()
        verify_immutable_core()
        ensure_brain_scaffold()

        # Pre-initialize DB before spawning daemons so children always
        # hit the _tables_exist() fast path (no write lock contention).
        try:
            from pulse.core.brain_db import get_conn
            get_conn()
            print("[PULSE] Brain DB pre-initialized.")
        except Exception as e:
            print(f"[PULSE] Brain DB pre-init warning: {e}")

        if SAFETY_CHECK.exists():
            print("[PULSE] Running safety check (mandatory)...")
            result = subprocess.run([sys.executable, str(SAFETY_CHECK)])
            if result.returncode != 0:
                print("[PULSE] Safety check failed. Aborting startup.")
                return
        else:
            print("[PULSE] Safety check missing. Aborting startup.")
            return

        # License-based daemon gating
        active_daemons = DAEMON_MAP
        try:
            from pulse.core.license import LicenseManager
            _lm = LicenseManager()
            _allowed = _lm.get_limits().get("daemons")
            if _allowed is not None:
                active_daemons = {k: v for k, v in DAEMON_MAP.items() if k in _allowed}
                print(f"[PULSE] {_lm.get_plan().upper()} plan: {len(active_daemons)}/{len(DAEMON_MAP)} daemons")
        except Exception:
            pass

        for i, (agent_id, script) in enumerate(active_daemons.items()):
            self.start_daemon(script, DAEMON_LABELS.get(agent_id, agent_id), agent_id)
            if i < len(active_daemons) - 1:
                time.sleep(0.5)  # Stagger: prevents SQLite connection burst
        _save_daemon_pids(self.processes)

        start_all_schedulers(lambda: self.running)

        # Wire event bus subscribers (Phase 0 — was 0 subscribers before)
        try:
            from pulse.core.event_subscribers import wire_all_subscribers
            n = wire_all_subscribers()
            if n:
                print(f"[PULSE] Event bus: {n} subscribers wired.")
        except Exception as e:
            print(f"[PULSE] Event bus wiring failed: {e}")

        print("\n[PULSE] Brain is listening and digesting on all channels.")
        print("[PULSE] Press Ctrl+C to stop.\n")

        restart_counts = defaultdict(int)
        last_restart_time = defaultdict(float)
        MAX_RESTARTS = 5
        COOLDOWN_RESET_SECS = 300  # Reset crash count after 5 min of stability

        while self.running:
            now = time.time()
            for name, p in list(self.processes.items()):
                if p.poll() is not None:
                    # Reset crash count if daemon was stable for COOLDOWN_RESET_SECS
                    if now - last_restart_time.get(name, 0) > COOLDOWN_RESET_SECS:
                        restart_counts[name] = 0

                    if restart_counts[name] < MAX_RESTARTS:
                        restart_counts[name] += 1
                        last_restart_time[name] = now
                        backoff = min(2 ** restart_counts[name], 30)
                        print(f"[PULSE] Daemon '{name}' died (exit={p.returncode}). "
                              f"Restarting in {backoff}s ({restart_counts[name]}/{MAX_RESTARTS})...")
                        time.sleep(backoff)
                        script = active_daemons.get(name)
                        if script and script.exists():
                            self.start_daemon(script, name, name)
                            _save_daemon_pids(self.processes)
                        else:
                            print(f"[PULSE] Cannot restart '{name}': script not found.")
                    else:
                        _write_daemon_alert(name, DAEMON_LABELS.get(name, name),
                                            p.returncode, MAX_RESTARTS)
                        print(f"[PULSE] Daemon '{name}' exceeded max restarts ({MAX_RESTARTS}). "
                              f"Cooling down 5min before retry...")
                        del self.processes[name]

            # Resurrect dead daemons after cooldown (never permanently abandon)
            for name, script in active_daemons.items():
                if name not in self.processes and script.exists():
                    if now - last_restart_time.get(name, 0) > COOLDOWN_RESET_SECS:
                        restart_counts[name] = 0
                        print(f"[PULSE] Resurrecting daemon '{name}' after cooldown...")
                        self.start_daemon(script, name, name)
                        _save_daemon_pids(self.processes)

            enforce_quarantine(self.processes, self.quarantine_events, DAEMON_MAP)
            time.sleep(1)

if __name__ == "__main__":
    PulseOrchestrator().run()
