# -*- coding: utf-8 -*-
"""Per-app internet channel rules + persistent catalog.

Policy:
  - Claude* always Squid (handled in builder, not editable here)
  - channel=direct → home ISP
  - channel=vpn → Sota VLESS
  - channel=proxy → same Squid as Claude (Cursor, Codex, …)
  - channel=block / missing → blocked until user chooses (ask)
"""
from __future__ import annotations

import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Literal

Channel = Literal["direct", "vpn", "proxy", "block", "pending"]

CHANNELS = ("direct", "vpn", "proxy", "block", "pending")
CLAUDE_EXES = {"claude.exe", "cowork-svc.exe"}

# Health-check binaries owned by CPC. Each is pinned by the builder to exactly one
# outbound so the monitor measures the channel it claims to measure. They must never
# appear in the UI or be reassignable, otherwise the probes stop being independent
# of the thing they verify.
PROBE_EXES: dict[str, str] = {
    "cpc-probe-direct.exe": "direct",
    "cpc-probe-vpn.exe": "vpn",
    "cpc-probe-squid.exe": "proxy",
}
RESERVED_EXES = set(PROBE_EXES)

# Windows / noise — auto home so ask dialog is not spam
SYSTEM_AUTO_DIRECT = {
    "system",
    "registry",
    "svchost.exe",
    "services.exe",
    "lsass.exe",
    "csrss.exe",
    "smss.exe",
    "wininit.exe",
    "winlogon.exe",
    "dwm.exe",
    "sihost.exe",
    "taskhostw.exe",
    "runtimebroker.exe",
    "dllhost.exe",
    "ctfmon.exe",
    "searchhost.exe",
    "searchindexer.exe",
    "startmenuexperiencehost.exe",
    "shellexperiencehost.exe",
    "textinputhost.exe",
    "securityhealthservice.exe",
    "smartscreen.exe",
    "nisrv.exe",
    "mpcmdrun.exe",
    "msmpeng.exe",
    "widgetservice.exe",
    "widgets.exe",
    "crossdeviceconsent.exe",
    "backgroundtaskhost.exe",
    "applicationframehost.exe",
    "systemsettings.exe",
    "wslservice.exe",
    "wslrelay.exe",
    "vmcompute.exe",
}

# Green = "hot" right now; gray-but-top = still in the recent band for a couple minutes
ACTIVE_GREEN_SEC = 45
RECENT_TOP_SEC = 120  # stay in upper list for 2 minutes after last activity
ACTIVE_RECENT_SEC = RECENT_TOP_SEC  # back-compat alias


def product_home() -> Path:
    env = os.environ.get("CLAUDE_PROXY_HOME", "").strip()
    if env:
        return Path(env)
    return Path(os.environ["USERPROFILE"]) / ".claude-proxy-control"


def rules_path() -> Path:
    p = product_home() / "merged" / "app-rules.json"
    p.parent.mkdir(parents=True, exist_ok=True)
    return p


def legacy_direct_path() -> Path:
    return product_home() / "merged" / "direct-apps.json"


def _now() -> str:
    return datetime.now().isoformat(timespec="seconds")


def _empty() -> dict[str, Any]:
    return {
        "version": 1,
        "policy": "ask_unknown",  # unmatched → block + ask; Claude → squid
        "default_choice": "direct",  # suggested in dialog
        "apps": [],
    }


def load_rules() -> dict[str, Any]:
    path = rules_path()
    data = _empty()
    if path.is_file():
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
            if isinstance(raw, dict):
                data.update(raw)
                if not isinstance(data.get("apps"), list):
                    data["apps"] = []
        except Exception:
            pass
    # migrate old direct-apps.json once
    if not data["apps"] and legacy_direct_path().is_file():
        try:
            legacy = json.loads(legacy_direct_path().read_text(encoding="utf-8"))
            if isinstance(legacy, list):
                for item in legacy:
                    if not isinstance(item, dict):
                        continue
                    exe = Path(str(item.get("exe") or "")).name
                    if not exe or exe.lower() in CLAUDE_EXES:
                        continue
                    data["apps"].append(
                        {
                            "exe": exe,
                            "path": str(item.get("path") or ""),
                            "channel": "direct",
                            "first_seen": item.get("added_at") or _now(),
                            "last_seen": item.get("added_at") or _now(),
                            "hits": 0,
                            "asked": True,
                        }
                    )
                save_rules(data)
        except Exception:
            pass
    return data


def save_rules(data: dict[str, Any]) -> None:
    merged = _empty()
    merged.update(data)
    if not isinstance(merged.get("apps"), list):
        merged["apps"] = []
    rules_path().write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
    # keep legacy file in sync for old tools (direct-only list)
    directs = [
        {"exe": a["exe"], "path": a.get("path") or "", "added_at": a.get("first_seen") or _now()}
        for a in merged["apps"]
        if isinstance(a, dict) and str(a.get("channel") or "") == "direct"
        and Path(str(a.get("exe") or "")).name.lower() not in CLAUDE_EXES
    ]
    legacy_direct_path().write_text(json.dumps(directs, ensure_ascii=False, indent=2), encoding="utf-8")


def _last_seen_dt(app: dict[str, Any]) -> datetime | None:
    ts = str(app.get("last_seen") or "").strip()
    if not ts:
        return None
    try:
        return datetime.fromisoformat(ts)
    except Exception:
        return None


def list_apps() -> list[dict[str, Any]]:
    """Upper: seen in last RECENT_TOP_SEC (pending first, then newest). Lower: A→Z."""
    apps = [
        a
        for a in load_rules().get("apps") or []
        if isinstance(a, dict)
        and a.get("exe")
        and Path(str(a.get("exe"))).name.lower() not in RESERVED_EXES
    ]
    now = datetime.now()

    def sort_key(a: dict[str, Any]) -> tuple:
        dt = _last_seen_dt(a)
        recent = bool(dt and dt >= now - timedelta(seconds=RECENT_TOP_SEC))
        pending = 0 if str(a.get("channel") or "") == "pending" else 1
        # recent first; within recent: pending, then newest last_seen; else alphabet
        if recent:
            ts = dt.timestamp() if dt else 0.0
            return (0, pending, -ts, str(a.get("exe") or "").lower())
        return (1, pending, 0.0, str(a.get("exe") or "").lower())

    apps.sort(key=sort_key)
    return apps


def get_app(exe: str) -> dict[str, Any] | None:
    key = Path(exe).name.lower()
    for a in load_rules().get("apps") or []:
        if isinstance(a, dict) and str(a.get("exe") or "").lower() == key:
            return a
    return None


def _is_active(app: dict[str, Any], recent_sec: int = ACTIVE_GREEN_SEC) -> bool:
    dt = _last_seen_dt(app)
    if not dt:
        return False
    return dt >= datetime.now() - timedelta(seconds=recent_sec)


def is_active(app: dict[str, Any], recent_sec: int = ACTIVE_GREEN_SEC) -> bool:
    """Green circle — activity in the last ~45s."""
    return _is_active(app, recent_sec=recent_sec)


def is_recent_top(app: dict[str, Any], recent_sec: int = RECENT_TOP_SEC) -> bool:
    """Stay in the upper 'online' band for ~2 minutes (may already be gray)."""
    return _is_active(app, recent_sec=recent_sec)


def _upsert_seen_into(
    data: dict[str, Any],
    exe: str,
    path: str = "",
    *,
    route: str = "",
    hits: int = 1,
) -> dict[str, Any]:
    """Mutate rules dict in memory (caller saves once)."""
    exe_name = Path(exe).name
    if not exe_name:
        raise ValueError("empty exe")
    apps: list[dict[str, Any]] = list(data.get("apps") or [])
    key = exe_name.lower()

    if key in RESERVED_EXES:
        # CPC's own probes: pinned by the builder, never catalogued or shown.
        raise ValueError(f"reserved exe: {exe_name}")

    if key in CLAUDE_EXES:
        channel: Channel = "vpn"
        asked = True
    elif key in SYSTEM_AUTO_DIRECT:
        channel = "direct"
        asked = True
    else:
        channel = "pending"
        asked = False

    found = None
    for a in apps:
        if str(a.get("exe") or "").lower() == key:
            found = a
            break
    now = _now()
    if found is None:
        found = {
            "exe": exe_name,
            "path": path or "",
            "channel": channel,
            "first_seen": now,
            "last_seen": now,
            "hits": int(hits),
            "asked": asked,
            "last_route": route or "",
        }
        apps.append(found)
    else:
        found["last_seen"] = now
        found["hits"] = int(found.get("hits") or 0) + int(hits)
        if path and (not found.get("path") or len(path) > len(str(found.get("path") or ""))):
            found["path"] = path
        if route:
            found["last_route"] = route
        ch = str(found.get("channel") or "pending")
        if ch not in CHANNELS:
            found["channel"] = "pending"
        if key in SYSTEM_AUTO_DIRECT and ch == "pending":
            found["channel"] = "direct"
            found["asked"] = True
        if key in CLAUDE_EXES:
            found["channel"] = "vpn"
            found["asked"] = True
    data["apps"] = apps
    return found


def upsert_seen(exe: str, path: str = "", *, route: str = "", hits: int = 1) -> dict[str, Any]:
    """Record that an app tried to use the network. Does not change channel unless new."""
    data = load_rules()
    found = _upsert_seen_into(data, exe, path, route=route, hits=hits)
    save_rules(data)
    return found


def set_channel(exe: str, channel: Channel, path: str = "") -> dict[str, Any]:
    exe_name = Path(exe).name
    if not exe_name:
        raise ValueError("empty exe")
    if exe_name.lower() in CLAUDE_EXES:
        raise ValueError("Claude всегда через Squid — канал менять нельзя")
    if exe_name.lower() in RESERVED_EXES:
        raise ValueError("Это служебная проверка CPC — канал у неё закреплён")
    if channel not in CHANNELS:
        raise ValueError(f"bad channel: {channel}")
    data = load_rules()
    apps: list[dict[str, Any]] = list(data.get("apps") or [])
    key = exe_name.lower()
    found = None
    for a in apps:
        if str(a.get("exe") or "").lower() == key:
            found = a
            break
    now = _now()
    prev_ch = str((found or {}).get("channel") or "")
    if found is None:
        found = {
            "exe": exe_name,
            "path": path or "",
            "channel": channel,
            "first_seen": now,
            "last_seen": now,
            "hits": 0,
            "asked": True,
            "last_route": "",
            "apply_pending": True,
        }
        apps.append(found)
    else:
        found["channel"] = channel
        found["asked"] = True
        found["last_seen"] = now
        found["apply_pending"] = True  # no old-channel leak until TUN reload
        if path:
            found["path"] = path
    data["apps"] = apps
    save_rules(data)
    try:
        from cpc_debug import clog

        clog(
            "rules.set_channel",
            "persisted app-rules.json",
            exe=exe_name,
            prev=prev_ch,
            channel=channel,
            apply_pending=True,
            path=path or found.get("path") or "",
        )
    except Exception:
        pass
    return found


# ── перенос каналов между компьютерами ──────────────────────────────────────
# Разобрать полсотни программ по каналам — работа на полчаса, и повторять её на
# каждой машине незачем. Файл переносится как есть: это просто список
# «программа → канал», без путей к чужим папкам и без ничего личного.

EXPORT_FORMAT = "cpc-channels"
EXPORT_VERSION = 1


def export_channels() -> dict[str, Any]:
    """Назначенные каналы одним словарём, годным для записи в файл."""
    rows = []
    for a in load_rules().get("apps") or []:
        exe = str(a.get("exe") or "").strip()
        ch = str(a.get("channel") or "")
        # pending — это «ещё не решили»; переносить нечего.
        if not exe or ch not in ("direct", "vpn", "proxy", "block"):
            continue
        rows.append({"exe": exe, "channel": ch})
    rows.sort(key=lambda r: r["exe"].lower())
    return {
        "format": EXPORT_FORMAT,
        "version": EXPORT_VERSION,
        "exported_at": _now(),
        "apps": rows,
    }


def import_channels(data: Any, *, replace: bool = False) -> dict[str, Any]:
    """Применить перенесённые каналы к правилам. Возвращает отчёт.

    Импортируемое значение ГЛАВНЕЕ существующего: если программа уже была
    назначена на другой канал, файл её переназначает. Иначе импорт зависел бы от
    того, что на машине уже успели настроить, и одинаковые файлы давали бы разный
    результат.

    Ничего не запускает и туннель не трогает: помечает записи как ждущие
    применения, а маршруты пользователь применяет кнопкой, когда захочет.
    `replace=True` дополнительно снимает каналы у программ, которых нет в файле,
    возвращая их в «спросить» — это перенос настройки целиком, а не дополнение.
    """
    if not isinstance(data, dict) or data.get("format") != EXPORT_FORMAT:
        raise ValueError("Это не файл каналов Claude Proxy Control")
    if int(data.get("version") or 0) > EXPORT_VERSION:
        raise ValueError("Файл от более новой версии программы — обновите её")
    rows = data.get("apps")
    if not isinstance(rows, list):
        raise ValueError("В файле нет списка приложений")

    applied: list[str] = []
    skipped: list[str] = []
    for row in rows:
        if not isinstance(row, dict):
            continue
        exe = Path(str(row.get("exe") or "")).name
        ch = str(row.get("channel") or "")
        if not exe or ch not in ("direct", "vpn", "proxy", "block"):
            skipped.append(f"{exe or '?'}: непонятный канал")
            continue
        low = exe.lower()
        # Claude и служебные пробы CPC переносу не подлежат: их каналы закреплены.
        if low in CLAUDE_EXES or low in RESERVED_EXES:
            skipped.append(f"{exe}: канал закреплён программой")
            continue
        try:
            set_channel(exe, ch)  # type: ignore[arg-type]
            applied.append(exe)
        except Exception as e:
            skipped.append(f"{exe}: {e}")

    cleared: list[str] = []
    if replace:
        keep = {str(r.get("exe") or "").lower() for r in rows if isinstance(r, dict)}
        data_now = load_rules()
        for a in data_now.get("apps") or []:
            exe = str(a.get("exe") or "")
            low = exe.lower()
            if not exe or low in keep or low in CLAUDE_EXES or low in RESERVED_EXES:
                continue
            if str(a.get("channel") or "") in ("direct", "vpn", "proxy", "block"):
                a["channel"] = "pending"
                a["asked"] = False
                a["apply_pending"] = True
                cleared.append(exe)
        if cleared:
            save_rules(data_now)

    return {"applied": applied, "skipped": skipped, "cleared": cleared}


def clear_apply_pending() -> None:
    """Call after TUN successfully reloaded with new channels."""
    data = load_rules()
    changed = False
    for a in list(data.get("apps") or []):
        if isinstance(a, dict) and a.get("apply_pending"):
            a["apply_pending"] = False
            changed = True
    if changed:
        save_rules(data)


def apps_awaiting_apply() -> list[dict[str, Any]]:
    """Программы, у которых канал уже выбран, но в туннель он ещё не попал.

    До перезапуска TUN их трафик держится в блоке, иначе они продолжали бы
    ходить по старому каналу. Нужно, чтобы честно показать это на главном экране.
    """
    return [
        a
        for a in (load_rules().get("apps") or [])
        if isinstance(a, dict) and a.get("apply_pending")
    ]


def pending_apps() -> list[dict[str, Any]]:
    return [
        a
        for a in list_apps()
        if str(a.get("channel") or "") == "pending"
        and str(a.get("exe") or "").lower() not in CLAUDE_EXES
        and str(a.get("exe") or "").lower() not in SYSTEM_AUTO_DIRECT
    ]


def channel_groups_for_builder() -> dict[str, list[str]]:
    """Return exe name lists for sing-box process_name rules (with case variants)."""
    direct: list[str] = []
    vpn: list[str] = []
    proxy: list[str] = []
    block: list[str] = []
    seen: set[str] = set()

    def add(lst: list[str], name: str) -> None:
        key = name.lower()
        if key in seen or key in CLAUDE_EXES or key in RESERVED_EXES:
            return
        seen.add(key)
        lst.append(name)
        if name != key:
            lst.append(key)

    # system auto-direct always
    for name in sorted(SYSTEM_AUTO_DIRECT):
        if name == "system":
            continue
        add(direct, name)

    # CPC_QUARANTINE=1 → apps with apply_pending go to BLOCK (stop old-channel leak).
    # Normal Start/Apply use saved channel (VPN/Дом/…).
    quarantine = os.environ.get("CPC_QUARANTINE", "").strip() == "1"

    for a in load_rules().get("apps") or []:
        if not isinstance(a, dict):
            continue
        exe = Path(str(a.get("exe") or "")).name
        if not exe:
            continue
        ch = str(a.get("channel") or "pending")
        if quarantine and bool(a.get("apply_pending")):
            add(block, exe)
            continue
        if ch == "direct":
            add(direct, exe)
        elif ch == "vpn":
            add(vpn, exe)
        elif ch == "proxy":
            add(proxy, exe)
        elif ch in {"block", "pending"}:
            add(block, exe)

    return {"direct": direct, "vpn": vpn, "proxy": proxy, "block": block}


def ingest_live_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Update catalog from traffic_live rows; ONE disk write. Return newly pending apps."""
    if not rows:
        return []
    data = load_rules()
    by_exe = {
        str(a.get("exe") or "").lower(): a
        for a in (data.get("apps") or [])
        if isinstance(a, dict) and a.get("exe")
    }
    newly_pending: list[dict[str, Any]] = []
    for r in rows:
        exe = str(r.get("exe") or "")
        path = str(r.get("path") or "")
        route = str(r.get("route") or "")
        if not exe:
            continue
        key = Path(exe).name.lower()
        # Собственные пробники CPC постоянно светятся в логе туннеля, а
        # _upsert_seen_into на них бросает исключение. Раньше это обрывало разбор
        # всей пачки до записи на диск: каталог переставал пополняться совсем, и
        # ни одна новая программа не доходила до вопроса о канале.
        if key in RESERVED_EXES:
            continue
        before = by_exe.get(key)
        before_ch = str((before or {}).get("channel") or "")
        try:
            app = _upsert_seen_into(data, exe, path, route=route, hits=1)
        except Exception:
            # Одна негодная строка не должна стоить всей пачки.
            continue
        by_exe[key] = app
        after_ch = str(app.get("channel") or "")
        if after_ch == "pending" and (before is None or before_ch != "pending"):
            newly_pending.append(app)
    save_rules(data)
    seen: set[str] = set()
    out: list[dict[str, Any]] = []
    for a in newly_pending:
        k = str(a.get("exe") or "").lower()
        if k in seen:
            continue
        seen.add(k)
        out.append(a)
    return out


# Back-compat shims used by older UI bits
def load_direct_apps() -> list[dict[str, Any]]:
    return [
        {"exe": a["exe"], "path": a.get("path") or "", "added_at": a.get("first_seen")}
        for a in list_apps()
        if str(a.get("channel") or "") == "direct"
    ]


def add_direct_app(exe: str, path: str = "") -> dict[str, Any]:
    return set_channel(exe, "direct", path=path)


def remove_direct_app(exe: str) -> bool:
    """Removing from 'home' list → pending (will ask again) — prefer set_channel vpn/block."""
    app = get_app(exe)
    if not app:
        return False
    set_channel(exe, "pending", path=str(app.get("path") or ""))
    return True


def is_direct(exe: str) -> bool:
    app = get_app(exe)
    return bool(app and str(app.get("channel") or "") == "direct")
