from __future__ import annotations

import json
import re
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, List

from armorer.compose_runtime import build_compose_service_command, load_compose_services

_CHANNEL_BIND_TIMEOUT_SECONDS = 90
_CHANNEL_VERIFY_TIMEOUT_SECONDS = 60
_CHANNEL_PAIR_TIMEOUT_SECONDS = 60


def _is_config_managed_channel_fallback(channel: str, result: subprocess.CompletedProcess[str]) -> bool:
    normalized_channel = str(channel or "").strip().lower()
    if normalized_channel not in {"telegram"}:
        return False
    combined = "\n".join(
        part.strip() for part in [str(result.stderr or ""), str(result.stdout or "")] if str(part or "").strip()
    ).lower()
    return f"unknown channel: {normalized_channel}" in combined


def _extract_json(text: str) -> Dict[str, Any]:
    raw = (text or "").strip()
    if not raw:
        return {}
    try:
        data = json.loads(raw)
        return data if isinstance(data, dict) else {}
    except Exception:
        pass
    start = raw.find("{")
    end = raw.rfind("}")
    if start != -1 and end != -1 and end > start:
        try:
            data = json.loads(raw[start : end + 1])
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}
    return {}


def _normalize_pairing_requests(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
    requests = payload.get("requests", []) if isinstance(payload, dict) else []
    results: List[Dict[str, Any]] = []
    for item in requests:
        if not isinstance(item, dict):
            continue
        code = str(item.get("code") or "").strip()
        request_id = str(item.get("id") or "").strip()
        if not code or not request_id:
            continue
        meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
        results.append(
            {
                "id": request_id,
                "code": code,
                "created_at": str(item.get("createdAt") or "").strip(),
                "last_seen_at": str(item.get("lastSeenAt") or "").strip(),
                "username": str(meta.get("username") or "").strip(),
                "first_name": str(meta.get("firstName") or "").strip(),
                "account_id": str(meta.get("accountId") or "").strip(),
            }
        )
    return results


def _normalize_device_requests(payload: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
    def _coerce(items: Any) -> List[Dict[str, Any]]:
        results: List[Dict[str, Any]] = []
        if not isinstance(items, list):
            return results
        for item in items:
            if not isinstance(item, dict):
                continue
            results.append(
                {
                    "request_id": str(item.get("requestId") or "").strip(),
                    "device_id": str(item.get("deviceId") or "").strip(),
                    "platform": str(item.get("platform") or "").strip(),
                    "client_id": str(item.get("clientId") or "").strip(),
                    "client_mode": str(item.get("clientMode") or "").strip(),
                    "role": str(item.get("role") or "").strip(),
                    "remote_ip": str(item.get("remoteIp") or "").strip(),
                    "public_key": str(item.get("publicKey") or "").strip(),
                    "ts": int(item.get("ts") or 0),
                    "approved_at_ms": int(item.get("approvedAtMs") or 0),
                }
            )
        return results

    return {
        "pending": _coerce(payload.get("pending")),
        "paired": _coerce(payload.get("paired")),
    }


def _normalize_allowlist_values(raw: str) -> List[str]:
    text = str(raw or "").strip()
    if not text or "config path not found" in text.lower():
        return []
    try:
        parsed = json.loads(text)
        if isinstance(parsed, list):
            return [str(item).strip() for item in parsed if str(item).strip()]
    except Exception:
        pass
    compact = text.strip()
    if compact in {"[]", "null", '""', "''"}:
        return []
    return [part.strip() for part in re.split(r"[\s,]+", compact) if part.strip()]


def discover_openclaw_pairing_requests(install_dir: Path, channel: str) -> List[Dict[str, Any]]:
    normalized_channel = str(channel or "").strip().lower()
    if normalized_channel != "telegram":
        return []
    compose_file = install_dir / "docker-compose.yml"
    if compose_file.exists():
        result = _run_openclaw_cli(
            install_dir,
            ["pairing", "list", normalized_channel, "--json"],
            timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
            allow_exec_delegate=True,
        )
        if result.returncode == 0:
            payload = _extract_json(result.stdout)
            return _normalize_pairing_requests(payload)
    pairing_file = install_dir / ".openclaw" / "credentials" / "telegram-pairing.json"
    if not pairing_file.exists():
        return []
    try:
        payload = json.loads(pairing_file.read_text())
    except Exception:
        return []
    return _normalize_pairing_requests(payload)


def discover_openclaw_device_pairings(install_dir: Path) -> Dict[str, List[Dict[str, Any]]]:
    compose_file = install_dir / "docker-compose.yml"
    if not compose_file.exists():
        return {"pending": [], "paired": []}
    result = _run_openclaw_cli(
        install_dir,
        ["devices", "list", "--json"],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    if result.returncode != 0:
        return {"pending": [], "paired": []}
    payload = _extract_json(result.stdout)
    return _normalize_device_requests(payload)


def approve_openclaw_browser_device_pairing(
    install_dir: Path,
    *,
    opened_at_ms: int = 0,
    max_age_ms: int = 120_000,
) -> Dict[str, Any]:
    pairs = discover_openclaw_device_pairings(install_dir)
    pending = list(pairs.get("pending", []))
    paired = list(pairs.get("paired", []))

    eligible_paired = [
        item
        for item in paired
        if str(item.get("client_id") or "").strip() == "openclaw-control-ui"
        and str(item.get("client_mode") or "").strip() == "webchat"
    ]
    if eligible_paired:
        return {
            "ok": True,
            "status": "already_paired",
            "approved": False,
            "request_id": "",
            "device_id": str(eligible_paired[-1].get("device_id") or "").strip(),
            "message": "Browser access was already approved for this OpenClaw dashboard.",
        }

    candidates = [
        item
        for item in pending
        if str(item.get("client_id") or "").strip() == "openclaw-control-ui"
        and str(item.get("client_mode") or "").strip() == "webchat"
    ]
    now_ms = int(time.time() * 1000)
    fresh: List[Dict[str, Any]] = []
    recent: List[Dict[str, Any]] = []
    for item in candidates:
        ts = int(item.get("ts") or 0)
        if ts and now_ms - ts > max_age_ms:
            continue
        recent.append(item)
        if opened_at_ms and ts and ts + 1_000 < opened_at_ms:
            continue
        fresh.append(item)
    if not fresh and len(recent) == 1:
        # Remote dashboard approval can arrive from a host with slight clock skew.
        # If there is exactly one recent browser pairing request, approve it rather
        # than treating it as missing solely because its timestamp predates opened_at_ms.
        fresh = list(recent)
    if not fresh:
        return {
            "ok": True,
            "status": "waiting",
            "approved": False,
            "request_id": "",
            "device_id": "",
            "message": "Waiting for OpenClaw to create the browser pairing request.",
        }
    fresh.sort(key=lambda item: int(item.get("ts") or 0))
    if len(fresh) > 1:
        return {
            "ok": False,
            "status": "ambiguous",
            "approved": False,
            "request_id": "",
            "device_id": "",
            "message": "OpenClaw found multiple pending browser pairing requests. Approve the correct device from the CLI.",
            "pending_count": len(fresh),
        }
    request = fresh[-1]
    request_id = str(request.get("request_id") or "").strip()
    if not request_id:
        return {
            "ok": False,
            "status": "invalid_request",
            "approved": False,
            "request_id": "",
            "device_id": "",
            "message": "OpenClaw reported a browser pairing request without a request ID.",
        }
    result = _run_openclaw_cli(
        install_dir,
        ["devices", "approve", request_id],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    if result.returncode != 0:
        detail = (result.stderr or result.stdout or "").strip()
        return {
            "ok": False,
            "status": "approve_failed",
            "approved": False,
            "request_id": request_id,
            "device_id": str(request.get("device_id") or "").strip(),
            "message": detail.splitlines()[-1] if detail else "OpenClaw could not approve the browser device.",
        }
    return {
        "ok": True,
        "status": "approved",
        "approved": True,
        "request_id": request_id,
        "device_id": str(request.get("device_id") or "").strip(),
        "message": "OpenClaw approved browser access for the native UI.",
    }


def _run_openclaw_cli(
    install_dir: Path,
    args: List[str],
    *,
    timeout: int | None = None,
    allow_exec_delegate: bool = False,
) -> subprocess.CompletedProcess[str]:
    services = load_compose_services(install_dir)
    built = build_compose_service_command(
        install_dir,
        services,
        "openclaw-cli",
        args,
        use_service_ports=False,
        prefer_exec_delegate=allow_exec_delegate,
        allow_any_exec_delegate=allow_exec_delegate,
    )
    try:
        return subprocess.run(
            built["cmd"],
            cwd=install_dir,
            capture_output=True,
            text=True,
            check=False,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired as exc:
        return subprocess.CompletedProcess(
            built["cmd"],
            124,
            stdout=str(exc.stdout or ""),
            stderr=f"Timed out after {timeout or 0}s while running: {' '.join(args)}",
        )


def seed_openclaw_telegram_allowlist(
    install_dir: Path,
    *,
    account_id: str,
    extra_ids: List[str] | None = None,
) -> Dict[str, Any]:
    account = str(account_id or "").strip()
    additional = [str(item).strip() for item in (extra_ids or []) if str(item).strip()]
    ids_to_add = list(dict.fromkeys([item for item in [account, *additional] if item]))
    if not ids_to_add:
        return {
            "ok": False,
            "seeded_ids": [],
            "allow_from": [],
            "group_allow_from": [],
            "warnings": ["No Telegram sender account ID was available for allowlist seeding."],
        }

    get_allow = _run_openclaw_cli(
        install_dir,
        ["config", "get", "channels.telegram.allowFrom"],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    get_group = _run_openclaw_cli(
        install_dir,
        ["config", "get", "channels.telegram.groupAllowFrom"],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    existing_allow = _normalize_allowlist_values(get_allow.stdout or get_allow.stderr or "")
    existing_group = _normalize_allowlist_values(get_group.stdout or get_group.stderr or "")
    next_allow = list(dict.fromkeys([*existing_allow, *ids_to_add]))
    next_group = list(dict.fromkeys([*existing_group, *ids_to_add]))

    warnings: List[str] = []
    set_policy = _run_openclaw_cli(
        install_dir,
        ["config", "set", "channels.telegram.groupPolicy", "allowlist"],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    if set_policy.returncode != 0:
        err = (set_policy.stderr or set_policy.stdout or "").strip()
        warnings.append(f"Failed to set Telegram groupPolicy=allowlist: {err.splitlines()[-1] if err else 'unknown error'}")

    allow_json = json.dumps(next_allow)
    set_allow = _run_openclaw_cli(
        install_dir,
        ["config", "set", "channels.telegram.allowFrom", allow_json, "--strict-json"],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    if set_allow.returncode != 0:
        err = (set_allow.stderr or set_allow.stdout or "").strip()
        warnings.append(f"Failed to set Telegram allowFrom: {err.splitlines()[-1] if err else 'unknown error'}")

    group_json = json.dumps(next_group)
    set_group = _run_openclaw_cli(
        install_dir,
        ["config", "set", "channels.telegram.groupAllowFrom", group_json, "--strict-json"],
        timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
        allow_exec_delegate=True,
    )
    if set_group.returncode != 0:
        err = (set_group.stderr or set_group.stdout or "").strip()
        warnings.append(
            f"Failed to set Telegram groupAllowFrom: {err.splitlines()[-1] if err else 'unknown error'}"
        )

    return {
        "ok": not warnings,
        "seeded_ids": ids_to_add,
        "allow_from": next_allow,
        "group_allow_from": next_group,
        "warnings": warnings,
    }


def bind_openclaw_channels(install_dir: Path, enabled_channels: List[str], secrets_map: Dict[str, str]) -> Dict[str, Any]:
    bound: List[str] = []
    config_managed: List[str] = []
    warnings: List[str] = []

    for channel in enabled_channels:
        if channel == "telegram":
            token = (secrets_map.get("TELEGRAM_BOT_TOKEN") or "").strip()
            if not token:
                warnings.append("Telegram enabled but TELEGRAM_BOT_TOKEN is missing, so Armorer could not bind it.")
                continue
            result = _run_openclaw_cli(
                install_dir,
                ["channels", "add", "--channel", "telegram", "--token", token],
                timeout=_CHANNEL_BIND_TIMEOUT_SECONDS,
                allow_exec_delegate=True,
            )
        elif channel == "discord":
            token = (secrets_map.get("DISCORD_BOT_TOKEN") or "").strip()
            if not token:
                warnings.append("Discord enabled but DISCORD_BOT_TOKEN is missing, so Armorer could not bind it.")
                continue
            result = _run_openclaw_cli(
                install_dir,
                ["channels", "add", "--channel", "discord", "--token", token],
                timeout=_CHANNEL_BIND_TIMEOUT_SECONDS,
                allow_exec_delegate=True,
            )
        elif channel == "slack":
            bot_token = (secrets_map.get("SLACK_BOT_TOKEN") or "").strip()
            app_token = (secrets_map.get("SLACK_APP_TOKEN") or "").strip()
            if not bot_token or not app_token:
                warnings.append("Slack enabled but SLACK_BOT_TOKEN or SLACK_APP_TOKEN is missing, so Armorer could not bind it.")
                continue
            result = _run_openclaw_cli(
                install_dir,
                ["channels", "add", "--channel", "slack", "--bot-token", bot_token, "--app-token", app_token],
                timeout=_CHANNEL_BIND_TIMEOUT_SECONDS,
                allow_exec_delegate=True,
            )
        else:
            warnings.append(f"Armorer does not know how to bind channel '{channel}'.")
            continue

        if result.returncode == 0:
            bound.append(channel)
        elif _is_config_managed_channel_fallback(channel, result):
            config_managed.append(channel)
        else:
            err = (result.stderr or result.stdout or "").strip()
            warnings.append(f"Failed to bind {channel}: {err.splitlines()[-1] if err else 'unknown error'}")

    return {
        "bound_channels": bound,
        "config_managed_channels": config_managed,
        "warnings": warnings,
    }


def verify_openclaw_channels(install_dir: Path, enabled_channels: List[str]) -> Dict[str, Any]:
    result = _run_openclaw_cli(
        install_dir,
        ["channels", "status", "--probe", "--json"],
        timeout=_CHANNEL_VERIFY_TIMEOUT_SECONDS,
    )
    payload = _extract_json(result.stdout)
    statuses: Dict[str, Dict[str, Any]] = {}
    warnings: List[str] = []

    if result.returncode == 124:
        for channel in enabled_channels:
            statuses[channel] = {
                "configured": False,
                "verified": False,
                "detail": f"verification timed out after {_CHANNEL_VERIFY_TIMEOUT_SECONDS}s",
            }
        warnings.append(
            f"Channel verification timed out after {_CHANNEL_VERIFY_TIMEOUT_SECONDS}s. Runtime remains configured, but Armorer could not confirm channel health yet."
        )
        return {
            "ok": False,
            "statuses": statuses,
            "warnings": warnings,
            "raw": payload,
        }

    if result.returncode != 0:
        detail = (result.stderr or result.stdout or "").strip()
        detail_line = detail.splitlines()[-1] if detail else "OpenClaw channel verification failed."
        for channel in enabled_channels:
            statuses[channel] = {
                "configured": False,
                "verified": False,
                "linked": False,
                "detail": detail_line,
            }
        warnings.append(detail_line)
        return {
            "ok": False,
            "statuses": statuses,
            "warnings": warnings,
            "raw": payload,
        }

    accounts = payload.get("channelAccounts", {}) if isinstance(payload.get("channelAccounts"), dict) else {}
    for channel in enabled_channels:
        raw_accounts = accounts.get(channel)
        if not isinstance(raw_accounts, list) or not raw_accounts:
            statuses[channel] = {
                "configured": False,
                "verified": False,
                "detail": "No configured channel account was reported by OpenClaw.",
            }
            warnings.append(f"{channel}: OpenClaw did not report a configured account after binding.")
            continue

        sample = raw_accounts[0] if isinstance(raw_accounts[0], dict) else {}
        probe = sample.get("probe", {}) if isinstance(sample.get("probe"), dict) else {}
        configured = bool(sample.get("configured", False))
        verified = bool(probe.get("ok", False))
        linked = bool(sample.get("linked", False))
        detail_bits: List[str] = []
        if isinstance(sample.get("connected"), bool):
            detail_bits.append("connected" if sample.get("connected") else "disconnected")
        if isinstance(sample.get("linked"), bool):
            detail_bits.append("linked" if sample.get("linked") else "not linked")
        if isinstance(sample.get("running"), bool):
            detail_bits.append("running" if sample.get("running") else "stopped")
        if isinstance(sample.get("lastError"), str) and sample.get("lastError"):
            detail_bits.append(f"error: {sample['lastError']}")
        if isinstance(probe.get("error"), str) and probe.get("error"):
            detail_bits.append(f"probe: {probe['error']}")

        statuses[channel] = {
            "configured": configured,
            "verified": verified,
            "linked": linked,
            "detail": ", ".join(detail_bits) or ("probe ok" if verified else "probe unavailable"),
        }
        if not verified:
            warnings.append(f"{channel}: configured={configured}, verified={verified}. {statuses[channel]['detail']}")

    return {
        "ok": result.returncode == 0,
        "statuses": statuses,
        "warnings": warnings,
        "raw": payload,
    }


def approve_openclaw_pairings(install_dir: Path, pairing_codes: Dict[str, str]) -> Dict[str, Any]:
    statuses: Dict[str, Dict[str, Any]] = {}
    warnings: List[str] = []

    for channel, raw_code in (pairing_codes or {}).items():
        code = str(raw_code or "").strip()
        if not code:
            continue
        result = _run_openclaw_cli(
            install_dir,
            ["pairing", "approve", channel, code],
            timeout=_CHANNEL_PAIR_TIMEOUT_SECONDS,
            allow_exec_delegate=True,
        )
        if result.returncode == 0:
            detail = (result.stdout or "").strip().splitlines()
            statuses[channel] = {
                "approved": True,
                "detail": detail[-1] if detail else "approved",
            }
            continue

        err = (result.stderr or result.stdout or "").strip()
        statuses[channel] = {
            "approved": False,
            "detail": err.splitlines()[-1] if err else "unknown error",
        }
        warnings.append(f"Failed to approve {channel} pairing: {statuses[channel]['detail']}")

    return {
        "ok": not warnings,
        "statuses": statuses,
        "warnings": warnings,
    }
