#!/usr/bin/env python3
"""Turn CoreSystemConfig.json into shell variable assignments, deriving everything
derivable along the way. Consumed by tools/lib/coreconfig.sh via `eval`.

THIS IS WHERE THE TWINNING ACTUALLY HAPPENS. The config states two numbers — the
container host's last octet and the LLM host's — and this script computes every
address and every service URL from them. So the container host's OLLAMA_HOST is the
LLM host's address *by construction*: change `llm_host.ip_last_octet` from 13 to 21 and
the container-host ISO's Ollama URL follows on the next build, with nothing to keep in
sync by hand and nothing that can drift.

The same applies to every kiosk: a door panel's IDENTITY_URL is
`http://<container_host_ip>:<ports.identity>`, computed here, never typed anywhere.

Usage:
  config-export.py <config.json>              # core values only
  config-export.py <config.json> --kiosk <hostname>            # + that kiosk's values
  config-export.py <config.json> --audio-endpoint <hostname>   # + that endpoint's values
"""

from __future__ import annotations

import json
import shlex
import sys
from pathlib import Path


def emit(name: str, value) -> None:
    if isinstance(value, bool):
        value = "true" if value else "false"
    print(f"{name}={shlex.quote(str(value))}")


def main(argv: list[str]) -> int:
    if len(argv) < 2:
        print("usage: config-export.py <config.json> [--kiosk <hostname>]", file=sys.stderr)
        return 2

    cfg = json.loads(Path(argv[1]).read_text())
    kiosk_hostname = None
    if "--kiosk" in argv:
        kiosk_hostname = argv[argv.index("--kiosk") + 1]
    audio_hostname = None
    if "--audio-endpoint" in argv:
        audio_hostname = argv[argv.index("--audio-endpoint") + 1]

    prefix = cfg["network"]["subnet_prefix"]
    ports = {k: v for k, v in cfg["ports"].items() if not k.startswith("_")}

    container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}"
    llm_ip = f"{prefix}.{cfg['llm_host']['ip_last_octet']}"
    gateway = f"{prefix}.{cfg['network']['gateway_last_octet']}"

    # --- Household / image basics ---
    emit("CORE_TIMEZONE", cfg["household"]["timezone"])
    emit("CORE_LOCALE", cfg["household"]["locale"])
    emit("CORE_KEYBOARD_LAYOUT", cfg["household"]["keyboard_layout"])
    emit("CORE_DEBIAN_RELEASE", cfg["household"]["debian_release"])

    # --- Network ---
    emit("CORE_SUBNET_PREFIX", prefix)
    emit("CORE_NETMASK", cfg["network"]["netmask"])
    emit("CORE_GATEWAY", gateway)
    emit("CORE_DNS_SERVERS", " ".join(cfg["network"]["dns_servers"]))
    emit("CORE_WIFI_SSID", cfg["network"]["wifi"].get("ssid", ""))
    emit("CORE_WIFI_PSK", cfg["network"]["wifi"].get("psk", ""))

    # --- The two core hosts, each aware of the other. This is the twinning. ---
    emit("CORE_CONTAINER_HOST_IP", container_ip)
    emit("CORE_CONTAINER_HOST_NAME", cfg["container_host"]["hostname"])
    emit("CORE_CONTAINER_HOST_DISK", cfg["container_host"]["install_disk"])
    emit("CORE_CONTAINER_HOST_USER", cfg["container_host"]["admin_username"])
    emit("CORE_LLM_HOST_IP", llm_ip)
    emit("CORE_LLM_HOST_NAME", cfg["llm_host"]["hostname"])
    emit("CORE_LLM_HOST_DISK", cfg["llm_host"]["install_disk"])
    emit("CORE_LLM_HOST_USER", cfg["llm_host"]["admin_username"])

    # --- LLM host settings ---
    llm = cfg["llm_host"]
    emit("CORE_LLM_TIER", llm["tier"])
    emit("CORE_LLM_TEXT_MODEL_GPU", llm["text_model_gpu"])
    emit("CORE_LLM_TEXT_MODEL_CPU", llm["text_model_cpu"])
    emit("CORE_LLM_VISION_MODEL", llm["vision_model"])
    emit("CORE_LLM_PULL_VISION_MODEL", llm.get("pull_vision_model", True))
    emit("CORE_LLM_KEEP_ALIVE", llm["keep_alive"])
    emit("CORE_LLM_MAX_LOADED_MODELS", llm["max_loaded_models"])
    emit("CORE_LLM_NUM_PARALLEL", llm["num_parallel"])

    # --- Ports, individually and as derived URLs ---
    for name, port in sorted(ports.items()):
        emit(f"CORE_PORT_{name.upper()}", port)

    # DERIVED URLS — the whole reason this file exists. Nothing below is ever written
    # by hand in a build script or an env file; every one is computed from an address
    # and a port that each appear exactly once in CoreSystemConfig.json.
    emit("CORE_HA_URL", f"http://{container_ip}:{ports['home_assistant']}")
    emit("CORE_MQTT_BROKER_HOST", container_ip)
    emit("CORE_MQTT_BROKER_PORT", ports["mqtt"])
    emit("CORE_IDENTITY_URL", f"http://{container_ip}:{ports['identity']}")
    emit("CORE_IDENTITY_WEB_URL", f"http://{container_ip}:{ports['identity_web']}")
    emit("CORE_PANTRY_VISION_URL", f"http://{container_ip}:{ports['pantry_vision']}")
    emit("CORE_PANTRY_WEB_URL", f"http://{container_ip}:{ports['pantry_web']}")
    emit("CORE_DIGEST_WEB_URL", f"http://{container_ip}:{ports['digest_web']}")
    emit("CORE_ADMIN_WEB_URL", f"http://{container_ip}:{ports['admin_web']}")
    emit("CORE_TRANSIT_URL", f"http://{container_ip}:{ports['transit']}")
    emit("CORE_OTP_URL", f"http://{container_ip}:{ports['otp']}")
    emit("CORE_NTFY_URL", f"http://{container_ip}:{ports['ntfy']}")
    emit("CORE_GALLERY_SMB_HOST", container_ip)
    emit("CORE_FRIGATE_URL", f"http://{container_ip}:{ports['frigate']}")
    emit("CORE_GROCY_URL", f"http://{container_ip}:{ports['grocy']}")
    # The one that points the OTHER way — the container host's services reaching the
    # LLM host. Derived from llm_host.ip_last_octet, so the pair can never disagree.
    emit("CORE_OLLAMA_HOST", f"http://{llm_ip}:{ports['ollama']}")

    # --- Secrets ---
    secrets = cfg.get("secrets", {})
    for key in ("identity_token", "pantry_vision_token", "transit_token", "mqtt_username",
                "mqtt_password", "ha_token", "checkmk_username", "checkmk_secret",
                "workshop_token", "gitea_url", "gitea_token", "gitea_owner",
                "freeipa_bind_password",
                "ssh_authorized_key", "kiosk_password", "admin_password_hash"):
        emit(f"CORE_{key.upper()}", secrets.get(key, ""))

    # --- The household's OPNsense firewalls, and CheckMK ---
    # The firewall list goes out as ONE JSON blob, unlike everything else here, and the
    # reason is that it is a list: emitting scalars per firewall would mean
    # CORE_OPNSENSE_0_BASE_URL and a shell loop reassembling them, which is a parser
    # nobody wants to own. Consumers write it straight into IDSconf.json.
    firewalls = cfg.get("opnsense", []) or []
    if isinstance(firewalls, dict):
        firewalls = [firewalls]  # the old single-object shape; see validate-config.py
    keys = (cfg.get("secrets", {}) or {}).get("opnsense_keys", {}) or {}
    resolved = []
    for fw in firewalls:
        if not isinstance(fw, dict) or not str(fw.get("base_url") or "").strip():
            continue
        pair = keys.get(str(fw.get("name") or ""), {}) or {}
        resolved.append({
            "name": fw.get("name", "main"),
            "base_url": str(fw.get("base_url", "")).rstrip("/"),
            "api_key": pair.get("api_key", ""),
            "api_secret": pair.get("api_secret", ""),
            "verify_tls": fw.get("verify_tls", True),
            "interfaces": fw.get("interfaces", []) or [],
            "max_alerts_scanned": fw.get("max_alerts_scanned", 5000),
            "top_signatures": fw.get("top_signatures", 8),
            "top_hosts": fw.get("top_hosts", 5),
            "packet_capture_reference": fw.get("packet_capture_reference", ""),
        })
    emit("CORE_OPNSENSE_JSON", json.dumps({"firewalls": resolved}))
    emit("CORE_OPNSENSE_COUNT", len(resolved))

    checkmk = cfg.get("checkmk", {}) or {}
    emit("CORE_CHECKMK_BASE_URL", str(checkmk.get("base_url", "")).rstrip("/"))
    emit("CORE_CHECKMK_SITE", checkmk.get("site", ""))
    emit("CORE_CHECKMK_VERIFY_TLS", checkmk.get("verify_tls", True))
    emit("CORE_CHECKMK_ONLY_PROBLEMS", checkmk.get("only_problems", True))
    emit("CORE_CHECKMK_MAX_ROWS", checkmk.get("max_rows", 200))

    # --- External identity provider (declaration only — nothing implements SSO yet) ---
    idp = cfg.get("identity_provider", {}) or {}
    emit("CORE_IDP_ISSUER_URL", str(idp.get("issuer_url", "")).rstrip("/"))
    emit("CORE_IDP_REALM", idp.get("realm", ""))
    emit("CORE_IDP_CLIENT_ID", idp.get("client_id", ""))
    emit("CORE_IDP_PROTECTED_HOSTS", " ".join(str(h) for h in idp.get("protected_hosts", []) or []))

    # --- FreeIPA (declaration only — the mirror is not implemented yet) ---
    ipa = cfg.get("freeipa", {}) or {}
    for key in ("server", "domain", "base_dn", "bind_dn", "household_group",
                "chore_exempt_group", "admin_group"):
        emit(f"CORE_FREEIPA_{key.upper()}", ipa.get(key, ""))
    emit("CORE_FREEIPA_VERIFY_TLS", ipa.get("verify_tls", True))
    emit("CORE_FREEIPA_SYNC_INTERVAL_MINUTES", ipa.get("sync_interval_minutes", 60))

    # --- Enable flags ---
    for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items():
        if not flag.startswith("_"):
            emit(f"CORE_ENABLE_{flag.upper()}", value)

    proxy = cfg.get("proxy", {}) or {}
    emit("CORE_PROXY_ENABLED", proxy.get("enabled", False))
    emit("CORE_PROXY_HOSTNAME", proxy.get("hostname", ""))
    emit("CORE_PROXY_TLS", proxy.get("tls", "internal"))
    emit("CORE_PROXY_CERT_FILE", proxy.get("cert_file", ""))
    emit("CORE_PROXY_KEY_FILE", proxy.get("key_file", ""))
    # The admin panel's own URL, derived like every other one. Through the proxy when
    # there is one, straight at identity-web when there isn't — so the printed URL is
    # always the one that actually works.
    if proxy.get("enabled") and proxy.get("hostname"):
        base = f"https://{proxy['hostname']}"
        emit("CORE_ADMIN_URL", f"{base}/admin.html?api={base}/api/identity")
        emit("CORE_PROXY_BASE_URL", base)
    else:
        emit("CORE_ADMIN_URL",
             f"http://{container_ip}:{ports['identity_web']}/admin.html"
             f"?api=http://{container_ip}:{ports['identity']}")
        emit("CORE_PROXY_BASE_URL", "")

    emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu"))

    # --- Stream Dock (desk-side OpenDeck surface; see stream-dock/README.md) ---------
    # Emitted like everything else so the dock's generator and its LED service read the
    # same file the rest of the household is built from — the HA URL in particular is
    # derived from the container host's octet here, never typed into a plugin's
    # settings box twice.
    dock = cfg.get("stream_dock", {}) or {}
    dock_leds = dock.get("knob_leds", {}) or {}
    emit("CORE_STREAM_DOCK_ENABLED", dock.get("enabled", False))
    emit("CORE_STREAM_DOCK_ROOM", dock.get("room", ""))
    emit("CORE_STREAM_DOCK_LIGHTS", " ".join(str(e) for e in dock.get("lights", []) or []))
    emit("CORE_STREAM_DOCK_RGB_STEP", dock.get("rgb_step", 8))
    emit("CORE_STREAM_DOCK_BRIGHTNESS_STEP_PCT", dock.get("brightness_step_pct", 5))
    emit("CORE_STREAM_DOCK_TICK_BUCKET_MS", dock.get("tick_bucket_ms", 120))
    # The websocket URL the Home Assistant plugin wants, and the plain HTTP base the LED
    # sync service polls. Same host, same port, one place.
    emit("CORE_STREAM_DOCK_HA_WS_URL", f"ws://{container_ip}:{ports['home_assistant']}/api/websocket")
    emit("CORE_STREAM_DOCK_LED_ENABLED", dock_leds.get("enabled", False))
    emit("CORE_STREAM_DOCK_LED_BRIGHTNESS", dock_leds.get("brightness", 100))
    emit("CORE_STREAM_DOCK_LED_MIN_CHANNEL", dock_leds.get("min_channel_led", 0))
    emit("CORE_STREAM_DOCK_LED_POLL_SECONDS", dock_leds.get("poll_seconds", 2.0))
    emit("CORE_STREAM_DOCK_LED_DEBOUNCE_MS", dock_leds.get("debounce_ms", 400))
    emit("CORE_STREAM_DOCK_LED_CONFIG_PATH", dock_leds.get("config_path", ""))
    emit("CORE_STREAM_DOCK_LED_APPLY_COMMAND", dock_leds.get("apply_command", ""))
    emit("CORE_STREAM_DOCK_LED_APPLY_STRATEGY", dock_leds.get("apply_strategy", "none"))
    emit("CORE_STREAM_DOCK_LED_APPLY_MIN_INTERVAL",
         dock_leds.get("apply_min_interval_seconds", 2.0))
    emit("CORE_STREAM_DOCK_LED_PLUGIN_PATTERN", dock_leds.get("plugin_process_pattern", "akp05"))
    # The layer gate. The dock's lighting controls sit on their own OpenDeck layer, so
    # the rings are only the LED service's to drive while that layer is the one showing.
    emit("CORE_STREAM_DOCK_LED_GATE_COMMAND", dock_leds.get("layer_gate_command", ""))
    emit("CORE_STREAM_DOCK_LED_IDLE_COLORS",
         ",".join(str(c) for c in dock_leds.get("idle_colors", []) or []))
    emit("CORE_STREAM_DOCK_LED_IDLE_CYCLE_SECONDS", dock_leds.get("idle_cycle_seconds", 3.0))
    emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out"))
    emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True))

    # --- The selected kiosk, if one was asked for ---
    if kiosk_hostname:
        matches = [k for k in cfg.get("kiosks", []) if k.get("hostname") == kiosk_hostname]
        if not matches:
            available = ", ".join(k.get("hostname", "?") for k in cfg.get("kiosks", []))
            print(f"echo 'error: no kiosk with hostname {kiosk_hostname!r} in the config "
                  f"(have: {available})' >&2; return 1 2>/dev/null || exit 1")
            return 1
        kiosk = matches[0]
        emit("CORE_KIOSK_TYPE", kiosk["type"])
        emit("CORE_KIOSK_HOSTNAME", kiosk["hostname"])
        emit("CORE_KIOSK_FRIENDLY_NAME", kiosk["friendly_name"])
        # The HA area this device physically lives in. Baked into the agent, published
        # as `suggested_area` in its MQTT discovery, so HA files the device in the right
        # room without anybody dragging it there in the UI — and so every per-room
        # feature (voice "turn the lights off in here", the floorplan, the workshop
        # assistant) has one answer to "what is in this room". Empty is allowed and
        # means "don't suggest"; see docs/rooms-and-endpoints.md.
        emit("CORE_KIOSK_ROOM", kiosk.get("room", ""))
        emit("CORE_KIOSK_USERNAME", kiosk["kiosk_username"])
        emit("CORE_KIOSK_VOICE_SATELLITE", kiosk.get("voice_satellite", False))
        emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False))
        emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False))
        emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False))
        # touch-panel only: Discord as a fourth app. Emitted for every kiosk type (like
        # the flags above) so a builder reads one variable without knowing whether the
        # panel in question asked for it.
        emit("CORE_KIOSK_ENABLE_DISCORD", kiosk.get("enable_discord", False))
        # A per-kiosk Debian release, falling back to the household one. Only the
        # steam-tv-box currently sets it: that machine renders games locally, and
        # bookworm's Mesa is too old for that, while nothing else here cares what Mesa
        # it has. Emitted for every kiosk (not just that type) so a builder can read one
        # variable without knowing whether an override was in play.
        emit("CORE_KIOSK_DEBIAN_RELEASE",
             kiosk.get("debian_release") or cfg["household"]["debian_release"])
        # steam-tv-box only: which GPU is in the machine, and whether to drive the TV
        # over HDMI-CEC. Both are decisions about physical hardware that the build host
        # cannot see, so they are configuration rather than detection.
        emit("CORE_KIOSK_GPU_VENDOR", kiosk.get("gpu_vendor", "amd"))
        emit("CORE_KIOSK_ENABLE_CEC", kiosk.get("enable_cec", True))
        # A per-kiosk wake word overrides the household default.
        emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu")))

    if audio_hostname:
        matches = [a for a in cfg.get("audio_endpoints", []) if a.get("hostname") == audio_hostname]
        if not matches:
            available = ", ".join(a.get("hostname", "?") for a in cfg.get("audio_endpoints", []))
            print(f"echo 'error: no audio endpoint with hostname {audio_hostname!r} in the config "
                  f"(have: {available})' >&2; return 1 2>/dev/null || exit 1")
            return 1
        endpoint = matches[0]
        emit("CORE_AUDIO_HOSTNAME", endpoint["hostname"])
        emit("CORE_AUDIO_FRIENDLY_NAME", endpoint["friendly_name"])
        emit("CORE_AUDIO_ROOM", endpoint.get("room", ""))
        emit("CORE_AUDIO_ARCH", endpoint["arch"])

    return 0


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