import json
import os
import re
import socket
import subprocess
import secrets
from pathlib import Path
from typing import Any, Dict, List, Optional

import yaml

from armorer.registry import AppRegistry


_OPENCLAW_GATEWAY_DEFAULT_PORT = "18789"
_OPENCLAW_BRIDGE_DEFAULT_PORT = "18790"
_OPENCLAW_LOCAL_BINDS = {"local", "loopback", "127.0.0.1", "::1", "localhost"}
_OPENCLAW_NON_HOST_BINDS = {"lan", "tailnet", "all", "0.0.0.0", "::"}
_OPENCLAW_CONFIG_DIR_DEFAULT = "./.openclaw"
_OPENCLAW_WORKSPACE_DIR_DEFAULT = "./workspace"
_OPENCLAW_LEGACY_LOCAL_IMAGE = "openclaw:local"
OPENCLAW_RUNTIME_IMAGE_DEFAULT = "ghcr.io/openclaw/openclaw:main"


def _openclaw_local_image_exists() -> bool:
    try:
        result = subprocess.run(
            ["docker", "image", "inspect", _OPENCLAW_LEGACY_LOCAL_IMAGE],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return False
    return result.returncode == 0


def resolve_openclaw_runtime_image(env: Dict[str, str], fallback: Optional[str] = None) -> str:
    image = str(env.get("OPENCLAW_IMAGE") or "").strip()
    if image:
        return image
    if _openclaw_local_image_exists():
        return _OPENCLAW_LEGACY_LOCAL_IMAGE
    if not fallback:
        fallback = OPENCLAW_RUNTIME_IMAGE_DEFAULT
    return fallback


def resolve_openclaw_runtime_image_from_compose_dir(compose_dir: Path, run_env: Dict[str, str]) -> str:
    """Resolve runtime image using env precedence, normalizing legacy local fallback."""
    merged = run_env.copy()
    env_path = Path(compose_dir) / ".env"
    if env_path.exists():
        merged.update(_load_env_map(env_path))
    return resolve_openclaw_runtime_image(merged)


def _load_env_map(env_path: Path) -> Dict[str, str]:
    env: Dict[str, str] = {}
    if not env_path.exists():
        return env
    try:
        lines = env_path.read_text().splitlines()
    except Exception:
        return env
    for line in lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        env[key.strip()] = value.strip()
    return env


def _save_env_map(env_path: Path, env: Dict[str, str]) -> None:
    existing_lines: List[str] = []
    if env_path.exists():
        try:
            existing_lines = env_path.read_text().splitlines()
        except Exception:
            existing_lines = []
    output: List[str] = []
    seen = set()
    for line in existing_lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or "=" not in line:
            output.append(line)
            continue
        key, _ = line.split("=", 1)
        key = key.strip()
        if key in env:
            output.append(f"{key}={env[key]}")
            seen.add(key)
        else:
            output.append(line)
    for key, value in env.items():
        if key not in seen:
            if output and output[-1].strip():
                output.append("")
            output.append(f"{key}={value}")
            seen.add(key)
    env_path.write_text("\n".join(output).rstrip() + "\n")


def resolve_openclaw_registered_install_path(path: Path | str) -> Path:
    raw = Path(str(path or "")).expanduser()
    candidates: List[Path] = []
    seen: set[str] = set()

    def _add(candidate: Path) -> None:
        key = str(candidate)
        if key in seen:
            return
        seen.add(key)
        candidates.append(candidate)

    _add(raw)
    host_root = str(os.getenv("HOST_PROJECT_ROOT") or "").strip()
    raw_text = str(raw)

    if os.path.exists("/.dockerenv"):
        if host_root and raw_text.startswith("/app/"):
            try:
                _add((Path(host_root) / raw.relative_to("/app")).expanduser())
            except Exception:
                pass
        if host_root and raw_text.startswith(host_root):
            try:
                _add((Path("/app") / raw.relative_to(host_root)).expanduser())
            except Exception:
                pass
    else:
        if raw_text.startswith("/app/"):
            try:
                rel = raw.relative_to("/app")
                _add((Path.cwd() / rel).expanduser())
                if host_root:
                    _add((Path(host_root) / rel).expanduser())
            except Exception:
                pass
        if raw_text.startswith("/app/installed/"):
            tail = raw_text.split("/app/installed/", 1)[1]
            _add((Path.cwd() / "installed" / tail).expanduser())
            _add((Path.home() / "workplace" / "SecureNeural" / "Armorer" / "installed" / tail).expanduser())

    for candidate in candidates:
        if candidate.exists():
            return candidate.resolve()
    return candidates[0]


def _port_is_bindable(port: int) -> bool:
    checked_any = False
    for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
        sock = None
        try:
            sock = socket.socket(family, socket.SOCK_STREAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind((host, port))
            checked_any = True
        except OSError:
            # Some container runtimes do not expose IPv6 loopback. Treat that as
            # unsupported rather than "port in use", otherwise Dockerized Armorer
            # can fail to allocate any ports.
            if family == socket.AF_INET6:
                continue
            return False
        finally:
            if sock is not None:
                sock.close()
    return checked_any


def _docker_published_ports() -> set[int]:
    ports: set[int] = set()
    try:
        result = subprocess.run(
            ["docker", "ps", "--format", "{{.Ports}}"],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return ports
    if result.returncode != 0:
        return ports
    for line in result.stdout.splitlines():
        for match in re.finditer(r":(\d+)(?:-(\d+))?->", line):
            try:
                start = int(match.group(1))
                end = int(match.group(2) or match.group(1))
                for port in range(start, end + 1):
                    ports.add(port)
            except Exception:
                continue
    return ports


def _other_openclaw_ports(current_compose_dir: Path) -> set[int]:
    ports: set[int] = set()
    candidate_dirs: List[Path] = []

    parent = current_compose_dir.parent
    if parent.exists():
        for child in parent.iterdir():
            if not child.is_dir() or child.resolve() == current_compose_dir.resolve():
                continue
            if child.name.startswith("openclaw"):
                candidate_dirs.append(child)

    registry = AppRegistry()
    for app in registry.list_apps():
        if app.name == current_compose_dir.name:
            continue
        if app.agent_id and app.agent_id != "openclaw":
            continue
        candidate_dir = Path(app.install_path)
        if candidate_dir not in candidate_dirs:
            candidate_dirs.append(candidate_dir)

    for candidate_dir in candidate_dirs:
        if not candidate_dir.exists() or candidate_dir.resolve() == current_compose_dir.resolve():
            continue
        env = _load_env_map(candidate_dir / ".env")
        for key in ("OPENCLAW_GATEWAY_PORT", "OPENCLAW_BRIDGE_PORT"):
            value = env.get(key, "").strip()
            if value.isdigit():
                ports.add(int(value))
    return ports


def ensure_openclaw_instance_ports(compose_dir: Path) -> Dict[str, str]:
    env_path = compose_dir / ".env"
    env = _load_env_map(env_path)

    gateway = env.get("OPENCLAW_GATEWAY_PORT", "").strip()
    bridge = env.get("OPENCLAW_BRIDGE_PORT", "").strip()
    if gateway.isdigit() and bridge.isdigit():
        return env

    reserved = _other_openclaw_ports(compose_dir)
    reserved.update(_docker_published_ports())
    base = int(gateway) if gateway.isdigit() else int(_OPENCLAW_GATEWAY_DEFAULT_PORT)
    max_port = 65534

    fallback_candidate: Optional[tuple[str, str]] = None

    for candidate in range(base, max_port, 2):
        bridge_candidate = candidate + 1
        if bridge_candidate > 65535:
            break
        if candidate in reserved or bridge_candidate in reserved:
            continue
        if _port_is_bindable(candidate) and _port_is_bindable(bridge_candidate):
            env["OPENCLAW_GATEWAY_PORT"] = str(candidate)
            env["OPENCLAW_BRIDGE_PORT"] = str(bridge_candidate)
            _save_env_map(env_path, env)
            return env
        if fallback_candidate is None:
            fallback_candidate = (str(candidate), str(bridge_candidate))

    if fallback_candidate is not None:
        env["OPENCLAW_GATEWAY_PORT"], env["OPENCLAW_BRIDGE_PORT"] = fallback_candidate
        _save_env_map(env_path, env)
        return env

    raise RuntimeError("Unable to allocate OpenClaw gateway/bridge ports")


def _resolve_openclaw_runtime_mount_path(compose_dir: Path, env: Dict[str, str], env_key: str, default_relative: str) -> Path:
    raw = str(env.get(env_key) or "").strip() or default_relative
    path = Path(raw).expanduser()
    if not path.is_absolute():
        path = (compose_dir / path).resolve()
    return path


def canonicalize_openclaw_mount_env_paths(compose_dir: Path, env: Dict[str, str]) -> tuple[Dict[str, str], bool]:
    runtime_dir = resolve_openclaw_registered_install_path(compose_dir)
    normalized = dict(env)
    changed = False
    for key, default in (
        ("OPENCLAW_CONFIG_DIR", _OPENCLAW_CONFIG_DIR_DEFAULT),
        ("OPENCLAW_WORKSPACE_DIR", _OPENCLAW_WORKSPACE_DIR_DEFAULT),
    ):
        resolved = _resolve_openclaw_runtime_mount_path(runtime_dir, normalized, key, default)
        rendered = str(resolved)
        if str(normalized.get(key) or "").strip() != rendered:
            normalized[key] = rendered
            changed = True
    return normalized, changed


def _resolve_compose_value(raw: Any, env: Dict[str, str]) -> str:
    if raw is None:
        return ""
    if isinstance(raw, (int, float)):
        return str(raw)
    if not isinstance(raw, str):
        return ""

    value = raw.strip()
    def _replace(match: re.Match[str]) -> str:
        key, default = match.groups()
        resolved = env.get(key)
        if resolved:
            return resolved
        return default or ""

    return re.sub(r"\$\{([^}:]+)(?::-([^}]*))?\}", _replace, value)


def _extract_gateway_port(compose_data: Dict[str, Any], env: Dict[str, str]) -> str:
    services = compose_data.get("services", {})
    gateway = services.get("openclaw-gateway", {})
    ports = gateway.get("ports") or []
    for entry in ports:
        if isinstance(entry, int):
            return str(entry)
        if not isinstance(entry, str):
            continue
        resolved = _resolve_compose_value(entry, env)
        parts = [part.strip() for part in resolved.split(":") if part.strip()]
        if len(parts) >= 2:
            return parts[-2]
        if parts:
            return parts[0]
    return env.get("OPENCLAW_GATEWAY_PORT", _OPENCLAW_GATEWAY_DEFAULT_PORT)


def _extract_gateway_bind(compose_data: Dict[str, Any], env: Dict[str, str]) -> str:
    services = compose_data.get("services", {})
    gateway = services.get("openclaw-gateway", {})
    command = gateway.get("command") or []
    if isinstance(command, str):
        command = command.split()
    if not isinstance(command, list):
        return env.get("OPENCLAW_GATEWAY_BIND", "lan")
    for idx, token in enumerate(command):
        if token == "--bind" and idx + 1 < len(command):
            resolved = _resolve_compose_value(command[idx + 1], env)
            return resolved or env.get("OPENCLAW_GATEWAY_BIND", "lan")
    return env.get("OPENCLAW_GATEWAY_BIND", "lan")


def derive_openclaw_control_ui_origins(compose_dir: Path) -> List[str]:
    compose_path = compose_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = compose_dir / "docker-compose.yaml"
    env = _load_env_map(compose_dir / ".env")
    compose_data: Dict[str, Any] = {}
    if compose_path.exists():
        try:
            compose_data = yaml.safe_load(compose_path.read_text()) or {}
        except Exception:
            compose_data = {}

    port = _extract_gateway_port(compose_data, env)
    bind = _extract_gateway_bind(compose_data, env).strip().lower()

    origins = [
        f"http://127.0.0.1:{port}",
        f"http://localhost:{port}",
        f"http://[::1]:{port}",
    ]
    if bind and bind not in _OPENCLAW_LOCAL_BINDS and bind not in _OPENCLAW_NON_HOST_BINDS:
        host = bind.strip("[]")
        if ":" in host and not host.startswith("["):
            host = f"[{host}]"
        origins.append(f"http://{host}:{port}")

    deduped: List[str] = []
    for origin in origins:
        if origin not in deduped:
            deduped.append(origin)
    return deduped


def ensure_openclaw_bootstrap_config(config_path: Path, compose_dir: Path) -> bool:
    env_path = compose_dir / ".env"
    env = _load_env_map(env_path)
    gateway_token = str(env.get("OPENCLAW_GATEWAY_TOKEN") or "").strip()
    if len(gateway_token) < 16:
        gateway_token = secrets.token_hex(32)
        env["OPENCLAW_GATEWAY_TOKEN"] = gateway_token
        _save_env_map(env_path, env)

    try:
        if config_path.exists():
            raw = json.loads(config_path.read_text())
            data = raw if isinstance(raw, dict) else {}
        else:
            data = {}
    except Exception:
        data = {}

    gateway = data.setdefault("gateway", {})
    if not isinstance(gateway, dict):
        gateway = {}
        data["gateway"] = gateway
    gateway.setdefault("mode", "local")

    auth = gateway.setdefault("auth", {})
    if not isinstance(auth, dict):
        auth = {}
        gateway["auth"] = auth
    auth["mode"] = "token"
    auth["token"] = gateway_token

    remote = gateway.setdefault("remote", {})
    if not isinstance(remote, dict):
        remote = {}
        gateway["remote"] = remote
    remote["token"] = gateway_token

    control_ui = gateway.setdefault("controlUi", {})
    if not isinstance(control_ui, dict):
        control_ui = {}
        gateway["controlUi"] = control_ui

    existing = control_ui.get("allowedOrigins")
    merged: List[str] = []
    if isinstance(existing, list):
        for item in existing:
            if isinstance(item, str) and item not in merged:
                merged.append(item)
    for origin in derive_openclaw_control_ui_origins(compose_dir):
        if origin not in merged:
            merged.append(origin)
    control_ui["allowedOrigins"] = merged

    serialized = json.dumps(data, indent=2) + "\n"
    try:
        current = config_path.read_text() if config_path.exists() else None
    except Exception:
        current = None
    if current == serialized:
        return False

    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(serialized)
    return True


def ensure_openclaw_runtime_bootstrap(compose_dir: Path) -> Dict[str, Path]:
    runtime_dir = resolve_openclaw_registered_install_path(compose_dir)
    env_path = runtime_dir / ".env"
    env = _load_env_map(env_path)
    env, changed = canonicalize_openclaw_mount_env_paths(runtime_dir, env)

    runtime_image = resolve_openclaw_runtime_image(env)
    if str(env.get("OPENCLAW_IMAGE") or "").strip() != runtime_image:
        env["OPENCLAW_IMAGE"] = runtime_image
        changed = True

    gateway_token = str(env.get("OPENCLAW_GATEWAY_TOKEN") or "").strip()
    if len(gateway_token) < 16:
        env["OPENCLAW_GATEWAY_TOKEN"] = secrets.token_hex(32)
        changed = True

    if changed or not env_path.exists():
        env_path.parent.mkdir(parents=True, exist_ok=True)
        _save_env_map(env_path, env)

    env = ensure_openclaw_instance_ports(runtime_dir)
    config_dir = _resolve_openclaw_runtime_mount_path(
        runtime_dir,
        env,
        "OPENCLAW_CONFIG_DIR",
        _OPENCLAW_CONFIG_DIR_DEFAULT,
    )
    workspace_dir = _resolve_openclaw_runtime_mount_path(
        runtime_dir,
        env,
        "OPENCLAW_WORKSPACE_DIR",
        _OPENCLAW_WORKSPACE_DIR_DEFAULT,
    )
    config_dir.mkdir(parents=True, exist_ok=True)
    workspace_dir.mkdir(parents=True, exist_ok=True)

    runtime_env_path = config_dir / ".env"
    runtime_env_path.parent.mkdir(parents=True, exist_ok=True)
    if not runtime_env_path.exists():
        runtime_env_path.write_text("")

    config_path = config_dir / "openclaw.json"
    ensure_openclaw_bootstrap_config(config_path, runtime_dir)

    return {
        "compose_dir": runtime_dir,
        "env_path": env_path,
        "config_dir": config_dir,
        "workspace_dir": workspace_dir,
        "runtime_env_path": runtime_env_path,
        "config_path": config_path,
    }


def validate_openclaw_runtime_bootstrap(compose_dir: Path) -> List[str]:
    runtime_dir = resolve_openclaw_registered_install_path(compose_dir)
    issues: List[str] = []
    env_path = runtime_dir / ".env"
    env = _load_env_map(env_path)
    config_dir = _resolve_openclaw_runtime_mount_path(
        runtime_dir,
        env,
        "OPENCLAW_CONFIG_DIR",
        _OPENCLAW_CONFIG_DIR_DEFAULT,
    )
    workspace_dir = _resolve_openclaw_runtime_mount_path(
        runtime_dir,
        env,
        "OPENCLAW_WORKSPACE_DIR",
        _OPENCLAW_WORKSPACE_DIR_DEFAULT,
    )
    config_path = config_dir / "openclaw.json"

    if not runtime_dir.exists():
        issues.append(f"runtime root is missing: {runtime_dir}")
    if not config_dir.exists():
        issues.append(f"config dir is missing: {config_dir}")
    if not workspace_dir.exists():
        issues.append(f"workspace dir is missing: {workspace_dir}")
    if not config_path.exists():
        issues.append(f"bootstrap config is missing: {config_path}")
        return issues
    if not str(env.get("OPENCLAW_IMAGE") or "").strip():
        issues.append("runtime env is missing OPENCLAW_IMAGE")
    gateway_token = str(env.get("OPENCLAW_GATEWAY_TOKEN") or "").strip()
    if len(gateway_token) < 16:
        issues.append("runtime env is missing a valid OPENCLAW_GATEWAY_TOKEN")

    try:
        data = json.loads(config_path.read_text())
    except Exception as exc:
        issues.append(f"bootstrap config is unreadable: {exc}")
        return issues

    gateway = data.get("gateway")
    if not isinstance(gateway, dict):
        issues.append("bootstrap config is missing gateway settings")
        return issues
    if str(gateway.get("mode") or "").strip().lower() != "local":
        issues.append("bootstrap config must set gateway.mode=local")

    control_ui = gateway.get("controlUi")
    origins = control_ui.get("allowedOrigins") if isinstance(control_ui, dict) else None
    if not isinstance(origins, list) or not any(str(item).strip() for item in origins):
        issues.append("bootstrap config must set gateway.controlUi.allowedOrigins")

    return issues
