# -*- coding: utf-8 -*-
"""Read-only client for sing-box's local Clash API.

Why this exists: applied-channels.json was produced by parsing the same config file the
UI had just written, so "what the tunnel is actually doing" could never disagree with
"what we asked for". Any drift between the built config and the running tunnel was
structurally invisible. The Clash API is the one source that reflects the live process.

Everything here is best-effort and never raises: the API is a diagnostic, and a missing
or unreachable controller must never break an apply.
"""
from __future__ import annotations

import json
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
from urllib import error, request

PROXY_HOME = Path(
    os.environ.get("CLAUDE_PROXY_HOME") or (Path(os.environ["USERPROFILE"]) / ".claude-proxy-control")
)
MERGED = PROXY_HOME / "merged"
CREDS_PATH = MERGED / "clash-api.json"


def load_creds() -> dict[str, Any]:
    """{host, port, secret} written by the builder, or {} if unavailable."""
    try:
        data = json.loads(CREDS_PATH.read_text(encoding="utf-8-sig"))
        if isinstance(data, dict) and data.get("port"):
            return data
    except Exception:
        pass
    return {}


def _get(endpoint: str, timeout: float = 3.0) -> Any:
    creds = load_creds()
    if not creds:
        return None
    host = str(creds.get("host") or "127.0.0.1")
    port = int(creds.get("port") or 0)
    secret = str(creds.get("secret") or "")
    if not port:
        return None
    req = request.Request(f"http://{host}:{port}{endpoint}")
    if secret:
        req.add_header("Authorization", f"Bearer {secret}")
    try:
        with request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except (error.URLError, OSError, ValueError, TimeoutError):
        return None


def available() -> bool:
    return _get("/version", timeout=2.0) is not None


def connections() -> list[dict[str, Any]]:
    data = _get("/connections")
    if not isinstance(data, dict):
        return []
    conns = data.get("connections")
    return [c for c in conns if isinstance(c, dict)] if isinstance(conns, list) else []


def live_channels() -> dict[str, str]:
    """exe name (lowercase) -> outbound tag currently carrying its traffic.

    Derived from active connections, so it only sees apps that are talking right now.
    An app that is quiet simply does not appear; absence is not evidence of a mismatch.
    """
    out: dict[str, str] = {}
    for conn in connections():
        meta = conn.get("metadata") or {}
        if not isinstance(meta, dict):
            continue
        proc = str(meta.get("processPath") or "").strip()
        if not proc:
            continue
        exe = Path(proc).name.lower()
        chains = conn.get("chains")
        if not isinstance(chains, list) or not chains:
            continue
        # chains is ordered outermost-first; the last entry is the actual outbound.
        tag = str(chains[-1] or "").strip()
        if exe and tag:
            out[exe] = tag
    return out


def compare_with_config(
    expected: dict[str, str],
    normalize: "Callable[[str], str] | None" = None,
) -> dict[str, Any]:
    """Compare live routing against what the config says it should be.

    `expected` is an exe->channel map ("vpn", "proxy", "direct", "block") while the live
    side reports raw outbound tags ("sota-vless", "claude-squid", ...). Without
    `normalize` those two vocabularies are compared directly and EVERY app looks wrong -
    "expected vpn, live sota-vless" is the same thing said twice. Pass the caller's
    tag->channel mapper so both sides speak one language.

    Only apps with live connections can be judged, so coverage is reported too.
    """
    live = live_channels()
    if not live:
        return {"available": available(), "checked": 0, "mismatch": {}, "live": {}}
    mismatch: dict[str, dict[str, str]] = {}
    checked = 0
    for exe, tag in live.items():
        want = expected.get(exe)
        if not want:
            continue
        got = normalize(tag) if normalize else tag
        checked += 1
        if want != got:
            mismatch[exe] = {"expected": want, "live": got, "live_tag": tag}
    return {"available": True, "checked": checked, "mismatch": mismatch, "live": live}


def blocked_now() -> list[str]:
    """Executables whose live connections are landing in the block outbound.

    route.final=block is the product's default for unknown apps, and sing-box's log line
    for a blocked connection carries no process name - so without this there was no way
    to answer "which app is being blocked right now".
    """
    return sorted({exe for exe, tag in live_channels().items() if tag == "block"})
