#!/usr/bin/env python3
"""
Provider/model switch watchdog.

Scans hermes log files for evidence of LLM provider or model changes since
the last run. Alerts Rob when:
  - The active model changed (could lose image/multimodal capability)
  - A fallback to OpenRouter was activated (potential cost spike)
  - The credential pool rotated (means a primary credential failed)
  - A text-only model is in use while we expect multimodal (image capability lost)

State is stored in ~/AppData/Local/hermes/provider_watchdog.state.json so
each run only reports NEW events.

Cron contract: prints nothing when no new events since last run. Prints
formatted alert(s) when something changed.
"""
import json
import re
import sys
import time
from pathlib import Path

LOGS_DIR = Path.home() / "AppData" / "Local" / "hermes" / "logs"
STATE_FILE = Path.home() / "AppData" / "Local" / "hermes" / "provider_watchdog.state.json"

# Log files we watch. We track byte offset so we only see new lines.
WATCHED_LOGS = [
    LOGS_DIR / "agent.log",
    LOGS_DIR / "gateway-stdio.log",
]

# Patterns that indicate something interesting happened.
# Each tuple: (compiled_regex, event_type, format_string)
PATTERNS = [
    # (re, event_type, alert_template) — alert_template gets named-group dict
    (
        re.compile(r"Fallback activated:\s+(?P<from_model>[\w./\-:]+)\s+→\s+(?P<to_model>[\w./\-:]+)\s+\((?P<to_provider>[\w]+)\)"),
        "model_fallback",
        "🔁 Model fallback: {from_model} → {to_model} (via {to_provider})",
    ),
    (
        re.compile(r"credential pool: rotated to (?P<key_label>[\w_]+)"),
        "credential_rotation",
        "🔑 Credential pool rotated to: {key_label}",
    ),
    (
        re.compile(r"Credential pool provider mismatch: pool=(?P<pool>\w+), agent=(?P<agent>\w+)"),
        "credential_mismatch",
        "⚠️ Credential pool mismatch: pool={pool} vs agent={agent} (fallback active)",
    ),
    (
        re.compile(r"agent\.chat_completion_helpers: Fallback to openrouter/(?P<model>[\w./\-:]+): (?P<reason>.+)"),
        "openrouter_fallback",
        "💸 Fell back to OpenRouter: {model} ({reason})",
    ),
    (
        re.compile(r"Restore skipped pool entry \w+ \((?P<key_env>\w+)\): provider (?P<provider>\w+) does not match primary provider (?P<primary>\w+)"),
        "pool_skip",
        "⏭️ Pool entry skipped: {key_env} provider={provider} vs primary={primary}",
    ),
    (
        re.compile(r"agent\.title_generator: Title generation failed:.*?model=(?P<model>[\w./\-:]+)"),
        "title_gen_failed",
        "❌ Title generation failed for model={model}",
    ),
    (
        re.compile(r"llama3\.2.*does not support thinking", re.IGNORECASE),
        "ollama_thinking_fail",
        "⚠️ Ollama llama3.2 thinking-mode failure (agent fell back elsewhere)",
    ),
]

# Models we know to be text-only. If the active model is one of these, we
# lost image/multimodal capability — that's a real alert.
TEXT_ONLY_MODELS = [
    "deepseek/deepseek-v3",
    "deepseek/deepseek-r1",
    "deepseek/deepseek-chat",
    "nvidia/llama-3.3-nemotron-super-49b-v1.5",
    # add more as discovered
]


def load_state() -> dict:
    if not STATE_FILE.exists():
        return {}
    try:
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def save_state(state: dict) -> None:
    STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")


def read_new_lines(path: Path, offset: int) -> tuple[str, int]:
    """Return (new_content, new_offset). Empty if file is gone or unchanged."""
    if not path.exists():
        return "", offset
    try:
        with path.open("r", encoding="utf-8", errors="replace") as f:
            f.seek(offset)
            new = f.read()
            new_offset = f.tell()
        return new, new_offset
    except OSError:
        return "", offset


def check_log(path: Path, state: dict) -> list[str]:
    """Scan a log file for new events since last state. Returns alert strings."""
    log_state = state.setdefault(
        str(path),
        {
            "offset": 0,
            "last_active_model": None,
            "last_active_provider": None,
        },
    )
    offset = log_state.get("offset", 0)
    content, new_offset = read_new_lines(path, offset)
    if not content:
        return []

    alerts: list[str] = []
    last_model = log_state.get("last_active_model")
    last_provider = log_state.get("last_active_provider")

    for line in content.splitlines():
        # Active model detection — track transitions
        m = re.search(
            r"OpenAI client created.*?provider=(?P<provider>\w+).*?model=(?P<model>[\w./\-:]+)",
            line,
        )
        if m:
            provider = m.group("provider")
            model = m.group("model")
            if model != last_model or provider != last_provider:
                alerts.append(f"🤖 Model switched: {last_model or '?'} ({last_provider or '?'}) → {model} ({provider})")
                # Capability check
                if any(model.startswith(t) for t in TEXT_ONLY_MODELS):
                    alerts.append(
                        f"🖼️⚠️ Image capability LOST — {model} is text-only. You won't be able to read screenshots."
                    )
                last_model = model
                last_provider = provider

        # Other event patterns
        for pat, evt_type, template in PATTERNS:
            mm = pat.search(line)
            if not mm:
                continue
            try:
                msg = template.format(**mm.groupdict())
            except KeyError:
                continue
            # De-dupe within this run: don't print the same alert text twice
            # in one watchdog cycle.
            if msg not in alerts:
                alerts.append(msg)

    log_state["offset"] = new_offset
    log_state["last_active_model"] = last_model
    log_state["last_active_provider"] = last_provider
    return alerts


def main() -> int:
    state = load_state()

    # First-run behavior: if the state file is empty (this is our first run),
    # prime it with current log offsets and emit no alerts. Rob only wants
    # alerts for things that happen AFTER the watchdog is installed.
    is_first_run = not state

    all_alerts: list[str] = []

    for log_path in WATCHED_LOGS:
        try:
            if is_first_run:
                # Prime: read current offset, save it, emit no alerts.
                _content, offset = read_new_lines(log_path, 0)
                state[str(log_path)] = {
                    "offset": offset,
                    "last_active_model": None,
                    "last_active_provider": None,
                }
            else:
                alerts = check_log(log_path, state)
                all_alerts.extend(alerts)
        except Exception as e:
            # Best-effort — never break the cron on a log parse error
            print(f"provider_watchdog: error scanning {log_path.name}: {e}", file=sys.stderr)

    save_state(state)

    if is_first_run:
        # Tell Rob the watchdog is active and what it'll be watching
        print(
            f"✅ Provider watchdog installed. Watching {len(WATCHED_LOGS)} log(s) for model/provider changes."
        )
        return 0

    if not all_alerts:
        return 0  # silent — no new events

    # Print a single headed block so the cron delivery shows the whole thing
    print(f"⚠️ Hermes provider/model events detected ({time.strftime('%Y-%m-%d %H:%M:%S %Z')}):")
    for a in all_alerts:
        print(f"  {a}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
