#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
# See LICENSE file for details.
"""PULSE Task Dispatcher Daemon (ASOP v1)

Manages task board lifecycle: stale claims, dependency unblocking, archival.
Default mode: zero LLM cost. Dependencies: Python stdlib only (Law 4)
"""
import logging
log = logging.getLogger("pulse.tasks.pulse_dispatcher")

import hashlib
import hmac
import json
import os
import random
import secrets
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent.parent
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))
from pulse.core.brain_utils import TASK_BOARD_FILE, atomic_update_json, now_iso as _now_iso
from pulse.tasks.decomposer import decompose_prompt
try:
    from pulse.tasks.task_complexity import apply_complexity_routing as _apply_complexity_routing
except Exception:
    _apply_complexity_routing = lambda tasks: None

from pulse.tasks.dispatcher_lifecycle import (
    check_stale_claims, unblock_dependents, apply_capability_routing,
    archive_completed, gc_stale_dispatches, score_agents_for_task,
    _detect_agent_divergence,
)

# P10-31: Drift Diffusion Model for agent selection
_DDM_THRESHOLD, _DDM_NOISE, _DDM_MAX_ROUNDS = 3.0, 0.3, 10


def _ddm_select(candidates: list, scores: dict) -> str:
    """Drift Diffusion Model: accumulate noisy evidence until threshold."""
    import random as _r
    if not candidates:
        raise ValueError("No candidates for DDM selection")
    accumulators = {c: 0.0 for c in candidates}
    for _ in range(_DDM_MAX_ROUNDS):
        for c in candidates:
            accumulators[c] += scores.get(c, 0.5) + _r.gauss(0, _DDM_NOISE)
            if accumulators[c] >= _DDM_THRESHOLD:
                return c
    return max(accumulators, key=accumulators.get)


POLL_INTERVAL = 2.0


def _ts():
    return int(datetime.now(timezone.utc).timestamp())


class TaskDispatcher:
    """Lifecycle manager for the ASOP task board."""
    def __init__(self, verbose=False):
        self.verbose = verbose
        self.provider = os.environ.get("DISPATCHER_PROVIDER", "none").lower()
        self.model = os.environ.get("DISPATCHER_MODEL", "")
        self.endpoint = os.environ.get("DISPATCHER_ENDPOINT", "")
        self._hmac_key = os.environ.get("PULSE_BRAIN_KEY", "").encode() or None  # H4 HMAC

    def _sign_dispatch(self, dispatch_entry: dict) -> str:
        """HMAC-SHA256 sign dispatch. Returns hex digest."""
        if not self._hmac_key:
            return ""
        signable = json.dumps({
            "dispatch_id": dispatch_entry["dispatch_id"],
            "prompt": dispatch_entry["prompt"],
            "dispatched_at": dispatch_entry["dispatched_at"],
            "task_count": len(dispatch_entry.get("tasks", [])),
        }, sort_keys=True).encode()
        return hmac.new(self._hmac_key, signable, hashlib.sha256).hexdigest()

    def verify_dispatch(self, dispatch_entry: dict) -> bool:
        """Verify HMAC signature."""
        if not self._hmac_key:
            return True
        sig = dispatch_entry.get("_signature", "")
        if not sig:
            return False
        return hmac.compare_digest(sig, self._sign_dispatch(dispatch_entry))

    def dispatch(self, prompt: str, tasks: list[dict], founder_override: bool = False) -> str:
        """Write dispatch to task board atomically (C4). Returns dispatch_id."""
        try:  # Layer 11: compliance
            from pulse.workers.runner.worker_compliance import check_task_compliance
            for t in tasks:
                passed, reason = check_task_compliance(t)
                if not passed:
                    t["status"] = "rejected"; t["rejection_reason"] = reason
        except ImportError:
            pass
        try:  # Item 144: complexity routing
            _apply_complexity_routing([t for t in tasks if t.get("status") != "rejected"])
        except Exception as _exc:
            pass  # QR14: silent — consider log.debug("%s", _exc)
        try:  # Phase 4: DDM-scored agent recommendation
            from pulse.tasks.evidence_accumulator import select_best_agent
            import json as _json
            reg_file = TASK_BOARD_FILE.parent / "agent_registry.json"
            if reg_file.exists():
                reg = _json.loads(reg_file.read_text(encoding="utf-8"))
                candidates = [a for a, d in reg.items() if d.get("status") in ("idle", "active", "booting")]
                if len(candidates) >= 2:
                    for t in tasks:
                        if t.get("status") == "rejected":
                            continue
                        best, scores = "", {}
                        try:
                            best, scores = select_best_agent(candidates, t)
                        except Exception as _exc:
                            pass  # QR14: silent — consider log.debug("%s", _exc)
                        if not best:  # Item 182: DDM fallback
                            try:
                                _fb = {c: 0.5 for c in candidates}
                                best = _ddm_select(candidates, _fb); scores = _fb
                                t["_ddm_fallback"] = True
                            except Exception as _exc:
                                pass  # QR14: silent — consider log.debug("%s", _exc)
                        if best:
                            t["recommended_agent"] = best
                        try:  # Item 117: prediction market bias by domain track record
                            domain = t.get("domain", "general")
                            from pulse.brain.metacognition.metamemory import get_agent_specialization as _gas
                            spec = _gas()
                            if spec:
                                _ds = {a: spec.get(a, {}).get(domain, {}).get("success_rate", 0.5) for a in candidates}
                                if _ds:
                                    t["_domain_recommended_agent"] = max(_ds, key=_ds.get)
                                    t["_domain_scores"] = _ds
                        except Exception as _exc:
                            pass  # QR14: silent — consider log.debug("%s", _exc)
                        try:  # Item 52: runner-up counterfactual
                            if isinstance(scores, dict) and len(scores) >= 2:
                                sc = sorted(scores.items(), key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0, reverse=True)
                                if len(sc) >= 2 and all(isinstance(v, (int, float)) for _, v in sc[:2]):
                                    t["_runner_up"] = sc[1][0]; t["_runner_up_score"] = sc[1][1]
                        except Exception as _exc:
                            pass  # QR14: silent — consider log.debug("%s", _exc)
        except Exception as _exc:
            pass  # QR14: silent — consider log.debug("%s", _exc)
        dispatch_id = f"d_{_ts()}_{secrets.token_hex(4)}"  # G1
        dispatch_entry = {"dispatch_id": dispatch_id, "prompt": prompt,
                         "dispatched_at": _now_iso(), "tasks": tasks}
        if founder_override:
            dispatch_entry["founder_override"] = True
        sig = self._sign_dispatch(dispatch_entry)
        if sig:
            dispatch_entry["_signature"] = sig

        def _add_dispatch(board):
            board.setdefault("dispatches", []).append(dispatch_entry)
            board["version"] = board.get("version", 0) + 1
            return board
        atomic_update_json(TASK_BOARD_FILE, _add_dispatch, default={})
        
        try: # QR8 Gap 1: Pre-emptive Context Loading
            from pulse.daemons.neural.predictive_coding import pre_load_task_context
            for t in tasks:
                if t.get("status") == "open":
                    pre_load_task_context(t)
        except Exception as _exc:
            pass  # QR14: silent — consider log.debug("%s", _exc)

        try:  # Item 70: mentalizing — track per-agent outcomes for prediction (Item 183: divergence detection)
            _AGENT_OUTCOMES = getattr(sys.modules[__name__], "_AGENT_OUTCOMES", {})
            for t in tasks:
                ag = t.get("recommended_agent", "")
                if ag:
                    k = f"{t.get('domain', 'general')}:{t.get('task_type', t.get('type', 'unknown'))}"
                    _AGENT_OUTCOMES.setdefault(k, []).append({"agent": ag, "outcome": t.get("status", "dispatched"),
                                                              "confidence": float(t.get("_runner_up_score", 0.5))})
                    _AGENT_OUTCOMES[k] = _AGENT_OUTCOMES[k][-20:]
            sys.modules[__name__]._AGENT_OUTCOMES = _AGENT_OUTCOMES
            _detect_agent_divergence(_AGENT_OUTCOMES)
        except Exception as _exc:
            pass  # QR14: silent — consider log.debug("%s", _exc)
        if self.verbose:
            print(f"[DISPATCHER] Dispatched {len(tasks)} tasks ({dispatch_id})")
        return dispatch_id

    def decompose_with_llm(self, prompt: str) -> list[dict]:
        """Decompose prompt via LLM (fallback: empty list)."""
        return decompose_prompt(prompt, provider=self.provider, model=self.model,
                                endpoint=self.endpoint, verbose=self.verbose)

    def score_agents(self, task: dict) -> list[tuple[str, float]]:
        """Score agents for task. Returns sorted list."""
        return score_agents_for_task(task)

    def run(self):
        """Poll loop: stale claims, unblock deps, archive completed."""
        print("[DISPATCHER] Task Dispatcher running...")
        while True:
            try:
                def _lifecycle(board):
                    if not board.get("dispatches"):
                        return board
                    if self._hmac_key:  # H4: Reject tampered dispatches
                        for d in board.get("dispatches", []):
                            if not self.verify_dispatch(d):
                                if not d.get("_unsigned_flagged"):
                                    d["_unsigned_flagged"] = True
                                    if not d.get("founder_override"):
                                        for t in d.get("tasks", []):
                                            if t.get("status") == "open":
                                                t["status"] = "rejected"
                                                t["rejection_reason"] = "unsigned_dispatch"
                                        print(f"[DISPATCHER] REJECTED: Unsigned dispatch '{d.get('dispatch_id', '?')}'")
                                    else:
                                        print(f"[DISPATCHER] WARNING: Unsigned dispatch '{d.get('dispatch_id', '?')}' (Founder override)")
                            else:
                                d.pop("_unsigned_flagged", None)
                    modified = False
                    modified |= check_stale_claims(board, self.verbose)
                    modified |= unblock_dependents(board, self.verbose)
                    modified |= apply_capability_routing(board, self.verbose)
                    modified |= archive_completed(board, self.verbose)
                    modified |= gc_stale_dispatches(board, max_age_hours=6, verbose=self.verbose)
                    if modified:
                        board["version"] = board.get("version", 0) + 1
                    return board
                atomic_update_json(TASK_BOARD_FILE, _lifecycle, default={})
                time.sleep(POLL_INTERVAL)
            except Exception as e:
                if self.verbose:
                    print(f"[DISPATCHER] Error: {e}")
                time.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    v = "--verbose" in sys.argv or "-v" in sys.argv
    TaskDispatcher(verbose=v).run()
