#!/usr/bin/env python3
"""Patch ODS's Hermes config without clobbering user sections.

Hermes copies extensions/services/hermes/cli-config.yaml.template to
data/hermes/config.yaml only on first start. Installer migrations need to
repair the small set of ODS-managed defaults in both files while preserving
the rest of the user's config.
"""

from __future__ import annotations

import argparse
import re
from pathlib import Path


def _top_level_block(lines: list[str], name: str) -> tuple[int, int] | None:
    start = None
    pattern = re.compile(rf"^{re.escape(name)}:\s*(?:#.*)?$")
    for idx, line in enumerate(lines):
        if start is None:
            if pattern.match(line):
                start = idx
            continue
        if line and not line.startswith((" ", "\t")) and not line.lstrip().startswith("#"):
            return start, idx
    if start is None:
        return None
    return start, len(lines)


def _child_block(lines: list[str], parent: tuple[int, int], name: str, indent: int) -> tuple[int, int] | None:
    start = None
    prefix = " " * indent
    pattern = re.compile(rf"^{prefix}{re.escape(name)}:\s*(?:#.*)?$")
    for idx in range(parent[0] + 1, parent[1]):
        line = lines[idx]
        if start is None:
            if pattern.match(line):
                start = idx
            continue
        if line.startswith(prefix) and line.strip() and not line.startswith(prefix + " ") and not line.lstrip().startswith("#"):
            return start, idx
    if start is None:
        return None
    return start, parent[1]


def _set_key(lines: list[str], block: tuple[int, int], key: str, value: str, indent: int) -> tuple[int, int]:
    prefix = " " * indent
    pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*.*$")
    for idx in range(block[0] + 1, block[1]):
        if pattern.match(lines[idx]):
            lines[idx] = f"{prefix}{key}: {value}"
            return block

    insert_at = block[1]
    lines.insert(insert_at, f"{prefix}{key}: {value}")
    return block[0], block[1] + 1


def _has_key(lines: list[str], block: tuple[int, int], key: str, indent: int) -> bool:
    prefix = " " * indent
    pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*.*$")
    return any(pattern.match(lines[idx]) for idx in range(block[0] + 1, block[1]))


def _key_value(lines: list[str], block: tuple[int, int], key: str, indent: int) -> str | None:
    prefix = " " * indent
    pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*(.*?)\s*(?:#.*)?$")
    for idx in range(block[0] + 1, block[1]):
        match = pattern.match(lines[idx])
        if match:
            return match.group(1).strip()
    return None


def _ensure_model(lines: list[str], model: str | None, base_url: str | None, context_length: int | None, api_key: str | None = None) -> None:
    block = _top_level_block(lines, "model")
    if block is None:
        insert = ["model:"]
        if model:
            insert.append(f'  default: "{model}"')
        if base_url:
            insert.append('  provider: "custom"')
            insert.append(f'  base_url: "{base_url}"')
        if api_key:
            insert.append(f'  api_key: "{api_key}"')
        if context_length:
            insert.append(f"  context_length: {context_length}")
        lines[:0] = insert + [""]
        return

    if model:
        block = _set_key(lines, block, "default", f'"{model}"', 2)
    if base_url:
        block = _set_key(lines, block, "base_url", f'"{base_url}"', 2)
    if api_key:
        block = _set_key(lines, block, "api_key", f'"{api_key}"', 2)
    if context_length:
        _set_key(lines, block, "context_length", str(context_length), 2)


def _ensure_provider_timeout(lines: list[str], provider: str = "custom", timeout_seconds: int = 180) -> None:
    """Add ODS's local-provider timeout default without clobbering operators.

    Hermes can spend a long time in local prefill before the first token on
    35B-class models. Existing configs from older ODS installs are missing
    providers.custom.request_timeout_seconds, so add it on migration. If the
    operator already tuned the provider timeout, leave their value untouched.
    Passing a non-default timeout updates only ODS's shipped 180s default,
    which lets platform installers tune slow local backends without replacing
    an operator's custom value.
    """

    block = _top_level_block(lines, "providers")
    if block is None:
        auxiliary = _top_level_block(lines, "auxiliary")
        model = _top_level_block(lines, "model")
        insert_at = auxiliary[0] if auxiliary else (model[1] if model else len(lines))
        payload = [
            "providers:",
            f"  {provider}:",
            f"    request_timeout_seconds: {timeout_seconds}",
            "",
        ]
        lines[insert_at:insert_at] = payload
        return

    provider_block = _child_block(lines, block, provider, 2)
    if provider_block is None:
        insert_at = block[1]
        lines[insert_at:insert_at] = [
            f"  {provider}:",
            f"    request_timeout_seconds: {timeout_seconds}",
        ]
        return

    existing = _key_value(lines, provider_block, "request_timeout_seconds", 4)
    if existing is None:
        _set_key(lines, provider_block, "request_timeout_seconds", str(timeout_seconds), 4)
    elif timeout_seconds != 180 and existing == "180":
        _set_key(lines, provider_block, "request_timeout_seconds", str(timeout_seconds), 4)


def _ensure_auxiliary(lines: list[str], context_length: int | None) -> None:
    if not context_length:
        return

    block = _top_level_block(lines, "auxiliary")
    if block is None:
        terminal = _top_level_block(lines, "terminal")
        insert_at = terminal[0] if terminal else len(lines)
        payload = [
            "auxiliary:",
            "  compression:",
            f"    context_length: {context_length}",
            "",
        ]
        lines[insert_at:insert_at] = payload
        return

    compression = _child_block(lines, block, "compression", 2)
    if compression is None:
        insert_at = block[1]
        lines[insert_at:insert_at] = [
            "  compression:",
            f"    context_length: {context_length}",
        ]
        return

    _set_key(lines, compression, "context_length", str(context_length), 4)


def _ensure_compression(lines: list[str]) -> None:
    """Set the compression block to ODS Talk's tuned values.

    Previous defaults (0.50 / 0.20 / 20) caused the agent to lose granular
    context mid-conversation when a single tool result briefly spiked
    context past the 50% threshold. Bumped per ODS Talk live-testing —
    see cli-config.yaml.template for the full reasoning.

    Idempotent: every install (fresh or upgrade) that runs this patcher
    converges /opt/data/config.yaml to these values, automatically
    migrating existing operator installs on the next bootstrap-upgrade.
    """
    block = _top_level_block(lines, "compression")
    if block is None:
        lines.extend(
            [
                "",
                "compression:",
                "  enabled: true",
                "  threshold: 0.75",
                "  target_ratio: 0.50",
                "  protect_last_n: 40",
            ]
        )
        return

    block = _set_key(lines, block, "enabled", "true", 2)
    block = _set_key(lines, block, "threshold", "0.75", 2)
    block = _set_key(lines, block, "target_ratio", "0.50", 2)
    _set_key(lines, block, "protect_last_n", "40", 2)


def _ensure_whatsapp_bridge(lines: list[str]) -> None:
    """Keep WhatsApp off by default while avoiding upstream's port 3000 bridge.

    Existing operator choices win: if a WhatsApp block already exists we do not
    change its enabled state, and if it already has extra.bridge_port we leave
    that value alone.
    """

    platforms = _top_level_block(lines, "platforms")
    if platforms is None:
        lines.extend(
            [
                "",
                "platforms:",
                "  whatsapp:",
                "    enabled: false",
                "    extra:",
                "      bridge_port: 3010",
            ]
        )
        return

    whatsapp = _child_block(lines, platforms, "whatsapp", 2)
    if whatsapp is None:
        insert_at = platforms[1]
        lines[insert_at:insert_at] = [
            "  whatsapp:",
            "    enabled: false",
            "    extra:",
            "      bridge_port: 3010",
        ]
        return

    extra = _child_block(lines, whatsapp, "extra", 4)
    if extra is None:
        insert_at = whatsapp[1]
        lines[insert_at:insert_at] = [
            "    extra:",
            "      bridge_port: 3010",
        ]
        return

    for idx in range(extra[0] + 1, extra[1]):
        if re.match(r"^\s{6}bridge_port:\s*.*$", lines[idx]):
            return
    _set_key(lines, extra, "bridge_port", "3010", 6)


def patch_config(
    path: Path,
    model: str | None,
    base_url: str | None,
    context_length: int | None,
    api_key: str | None = None,
    request_timeout_seconds: int = 180,
) -> bool:
    original = path.read_text(encoding="utf-8")
    trailing_newline = original.endswith("\n")
    lines = original.splitlines()

    _ensure_model(lines, model, base_url, context_length, api_key)
    _ensure_provider_timeout(lines, timeout_seconds=request_timeout_seconds)
    _ensure_auxiliary(lines, context_length)
    _ensure_whatsapp_bridge(lines)
    _ensure_compression(lines)

    updated = "\n".join(lines)
    if trailing_newline:
        updated += "\n"
    if updated == original:
        return False
    path.write_text(updated, encoding="utf-8")
    return True


def main() -> int:
    parser = argparse.ArgumentParser(description="Patch ODS Hermes config defaults.")
    parser.add_argument("path", type=Path)
    parser.add_argument("--model")
    parser.add_argument("--base-url")
    parser.add_argument("--api-key", help="Bearer token Hermes uses to call the LLM (needed when routing through litellm)")
    parser.add_argument("--context-length", type=int)
    parser.add_argument("--request-timeout-seconds", type=int, default=180)
    args = parser.parse_args()

    if not args.path.exists():
        return 0
    changed = patch_config(
        args.path,
        args.model,
        args.base_url,
        args.context_length,
        args.api_key,
        args.request_timeout_seconds,
    )
    print("changed" if changed else "unchanged")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
