"""Run command and run-only helpers: repair memory, port/env parsing, UI actions, error signatures."""

import hashlib
import json
import os
import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

import yaml

from armorer.cli import common as cli_common
from armorer.cli import registry_helpers as cli_registry
from armorer import docker_runner
from armorer.apps.openclaw import runtime_cli as openclaw_runtime_cli


def extract_run_cli_overrides(ctx_args: List[str]) -> Tuple[List[str], Optional[str], bool]:
    """
    Recover known run flags from ctx.args when Typer leaves them in the
    passthrough arg list because this command also accepts arbitrary extra args.
    """
    cleaned_args: List[str] = []
    mode_override: Optional[str] = None
    change_override = False
    i = 0
    while i < len(ctx_args):
        arg = ctx_args[i]
        if arg == "--mode" and i + 1 < len(ctx_args):
            mode_override = ctx_args[i + 1]
            i += 2
            continue
        if arg.startswith("--mode="):
            mode_override = arg.split("=", 1)[1]
            i += 1
            continue
        if arg == "--change":
            change_override = True
            i += 1
            continue
        cleaned_args.append(arg)
        i += 1
    return cleaned_args, mode_override, change_override


def handle_run_failure(
    service_name: str,
    install_dir: Path,
    extra_args: list,
    *,
    repair_func: Callable[..., bool],
) -> bool:
    """Compatibility wrapper; interactive failures should call attempt_agentic_run_repair via repair_func."""
    return repair_func(
        agent=service_name,
        service_name=service_name,
        install_dir=install_dir,
        extra_args=extra_args,
        mode="interactive",
    )


def repair_memory_path() -> Path:
    p = cli_common.armorer_config_dir() / "repair_memory.json"
    try:
        p.parent.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass
    return p


def load_repair_memory() -> Dict[str, Any]:
    p = repair_memory_path()
    if not p.exists():
        return {"entries": {}}
    try:
        data = json.loads(p.read_text())
        if isinstance(data, dict) and isinstance(data.get("entries"), dict):
            return data
    except Exception:
        pass
    return {"entries": {}}


def save_repair_memory(memory: Dict[str, Any]) -> None:
    try:
        repair_memory_path().write_text(json.dumps(memory, indent=2))
    except Exception:
        pass


def extract_error_signature(error_text: str) -> str:
    t = (error_text or "").lower()
    if "password authentication failed for user \"supabase_auth_admin\"" in t:
        return "supabase_auth_password_mismatch"
    if "dependency failed to start" in t and "redis" in t and "exited (0)" in t:
        return "redis_dependency_exited_zero"
    if "no docker-compose file found" in t:
        return "compose_file_missing"
    first = next((ln.strip() for ln in (error_text or "").splitlines() if ln.strip()), "unknown_error")
    digest = hashlib.sha1(first.encode("utf-8", errors="ignore")).hexdigest()[:12]
    return f"generic_{digest}"


def signature_default_action(signature: str) -> Optional[Dict[str, Any]]:
    if signature == "supabase_auth_password_mismatch":
        return {"kind": "compose_down", "args": ["-v"], "label": "Reset stale DB volumes"}
    if signature == "redis_dependency_exited_zero":
        return {"kind": "compose_down", "args": [], "label": "Clean stale compose state"}
    return None


def extract_port_conflict(error_text: str) -> Optional[int]:
    t = str(error_text or "")
    m = re.search(r"Bind for 0\.0\.0\.0:(\d+)\s+failed:\s+port is already allocated", t, re.IGNORECASE)
    if m:
        try:
            return int(m.group(1))
        except Exception:
            return None
    return None


def containers_using_port(port: int) -> List[str]:
    try:
        res = docker_runner.docker_ps(
            args=[
                "--filter",
                f"publish={int(port)}",
                "--format",
                "{{.Names}}",
            ]
        )
        names = [ln.strip() for ln in (res.stdout or "").splitlines() if ln.strip()]
        return names
    except Exception:
        return []


def docker_published_ports() -> set:
    ports: set = set()
    try:
        res = docker_runner.docker_ps(
            args=[
                "--format",
                "{{.Ports}}",
            ]
        )
        for line in (res.stdout or "").splitlines():
            for m in re.finditer(r":(\d+)->", line):
                try:
                    ports.add(int(m.group(1)))
                except Exception:
                    continue
    except Exception:
        return ports
    return ports


def next_free_port(start: int, used: set) -> int:
    p = int(start)
    for _ in range(2000):
        if p not in used:
            return p
        p += 1
    return int(start)


def upsert_env_values(env_path: Path, updates: Dict[str, str]) -> None:
    lines: List[str] = []
    existing: Dict[str, str] = {}
    if env_path.exists():
        lines = env_path.read_text().splitlines()
        for line in lines:
            if "=" in line and not line.strip().startswith("#"):
                k, _ = line.split("=", 1)
                existing[k.strip()] = line
    else:
        env_path.parent.mkdir(parents=True, exist_ok=True)

    for key, value in updates.items():
        new_line = f"{key}={value}"
        if key in existing:
            for i, line in enumerate(lines):
                if line.startswith(f"{key}="):
                    lines[i] = new_line
                    break
        else:
            lines.append(new_line)
    env_path.write_text("\n".join(lines).strip() + "\n")


def resolve_host_port_token(token: str, env: Dict[str, str]) -> Tuple[Optional[int], Optional[str]]:
    t = str(token or "").strip()
    if not t:
        return None, None
    m = re.search(r"\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::-\s*([^}]+))?\s*\}", t)
    if m:
        var = m.group(1)
        default = str(m.group(2) or "").strip()
        raw = str(env.get(var) or os.getenv(var) or default or "").strip()
        try:
            return int(raw), var
        except Exception:
            return None, var
    try:
        return int(t), None
    except Exception:
        return None, None


def parse_host_port_from_string_port(
    spec: str, env: Dict[str, str]
) -> Tuple[Optional[int], Optional[str], Optional[int]]:
    s = str(spec or "").strip()
    if not s:
        return None, None, None
    core = s.split("/", 1)[0]
    parts: List[str] = []
    buf: List[str] = []
    brace_depth = 0
    i = 0
    while i < len(core):
        ch = core[i]
        if ch == "$" and i + 1 < len(core) and core[i + 1] == "{":
            brace_depth += 1
            buf.append(ch)
            i += 1
            buf.append(core[i])
        elif ch == "}" and brace_depth > 0:
            brace_depth -= 1
            buf.append(ch)
        elif ch == ":" and brace_depth == 0:
            parts.append("".join(buf).strip())
            buf = []
        else:
            buf.append(ch)
        i += 1
    parts.append("".join(buf).strip())
    if len(parts) < 2:
        return None, None, None
    host_idx = len(parts) - 2
    host_token = parts[host_idx]
    host_port, var_name = resolve_host_port_token(host_token, env)
    return host_port, var_name, host_idx


def auto_remap_conflicting_ports(
    install_dir: Path, target_port: Optional[int] = None
) -> Dict[int, int]:
    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return {}

    try:
        data = yaml.safe_load(compose_path.read_text()) or {}
    except Exception:
        return {}
    services = data.get("services", {})
    if not isinstance(services, dict):
        return {}

    env_path = install_dir / ".env"
    env_map: Dict[str, str] = {}
    if env_path.exists():
        try:
            from dotenv import dotenv_values
            env_map = {k: str(v) for k, v in (dotenv_values(env_path) or {}).items() if k}
        except Exception:
            env_map = {}

    used = docker_published_ports()
    updates: Dict[str, str] = {}
    remaps: Dict[int, int] = {}
    compose_changed = False

    for svc in services.values():
        if not isinstance(svc, dict):
            continue
        ports = svc.get("ports")
        if not isinstance(ports, list):
            continue
        new_ports: List[Any] = []
        for entry in ports:
            updated_entry = entry
            if isinstance(entry, str):
                host_port, var_name, host_idx = parse_host_port_from_string_port(entry, env_map)
                if host_port is not None and (target_port is None or host_port == target_port) and host_port in used:
                    new_host = next_free_port(host_port + 1000, used)
                    used.add(new_host)
                    remaps[host_port] = new_host
                    if var_name:
                        updates[var_name] = str(new_host)
                        env_map[var_name] = str(new_host)
                    elif host_idx is not None:
                        core = str(entry).split("/", 1)[0]
                        suffix = ""
                        if "/" in str(entry):
                            suffix = "/" + str(entry).split("/", 1)[1]
                        parts = [p.strip() for p in core.split(":")]
                        parts[host_idx] = str(new_host)
                        updated_entry = ":".join(parts) + suffix
                        compose_changed = True
                new_ports.append(updated_entry)
            elif isinstance(entry, dict):
                published = entry.get("published")
                host_port, var_name = resolve_host_port_token(str(published or ""), env_map)
                if host_port is not None and (target_port is None or host_port == target_port) and host_port in used:
                    new_host = next_free_port(host_port + 1000, used)
                    used.add(new_host)
                    remaps[host_port] = new_host
                    if var_name:
                        updates[var_name] = str(new_host)
                        env_map[var_name] = str(new_host)
                    else:
                        entry["published"] = new_host
                        compose_changed = True
                new_ports.append(entry)
            else:
                new_ports.append(entry)
        svc["ports"] = new_ports

    if updates:
        upsert_env_values(env_path, updates)
    if compose_changed:
        compose_path.write_text(yaml.dump(data, sort_keys=False))
    return remaps


def normalize_openclaw_run_commands(run_commands: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
    return openclaw_runtime_cli.normalize_openclaw_run_commands(run_commands)


def is_sensitive_text_ui_action(action: Dict[str, Any]) -> bool:
    if str(action.get("type", "")).strip().lower() != "text":
        return False
    if bool(action.get("sensitive")):
        return True
    haystacks = [
        str(action.get("message", "") or ""),
        str(action.get("label", "") or ""),
        str(action.get("placeholder", "") or ""),
    ]
    sensitive_terms = ("token", "secret", "password", "api key", "api_key")
    if any(term in text.lower() for text in haystacks for term in sensitive_terms):
        return True
    default = str(action.get("default", "") or "").strip()
    if not default:
        return False
    low_default = default.lower()
    if low_default.startswith(("sk-", "xoxb-", "ghp_", "gho_", "ghu_", "ghs_", "pat_", "api_", "tok_")):
        return True
    if re.fullmatch(r"(?i)AIza[0-9A-Za-z\-_]{20,}", default):
        return True
    if ":" in default:
        left, right = default.split(":", 1)
        if left.isdigit() and len(right.strip()) >= 8:
            return True
    compact = default.strip()
    if " " not in compact and len(compact) >= 24 and all(ch.isalnum() or ch in "._:-" for ch in compact):
        return True
    return False


def redact_ui_followup_for_display(action: Dict[str, Any], followup: str) -> str:
    text = str(followup or "")
    if not text or not is_sensitive_text_ui_action(action):
        return text
    if text.strip().lower() == "ui input: skip":
        return "UI input: skip"
    if text.lower().startswith("ui input:"):
        return "UI input: [redacted]"
    return "[redacted]"


def ui_action_prompt_message(action: Dict[str, Any]) -> str:
    raw_message = action.get("message")
    if raw_message is None:
        return "Input required"
    message = str(raw_message).strip()
    return message or "Input required"


def openclaw_is_read_only_check(run_args: List[str]) -> bool:
    return openclaw_runtime_cli.openclaw_is_read_only_check(run_args)


def resolve_setup_cmd(service_name: str, install_dir: Path, setup_parts: list) -> Tuple[str, List[str], list]:
    """
    Figure out the right service + args to run a CLI command (e.g. ['openclaw', 'setup'])
    inside the compose stack.  Returns (service, docker_flags, cmd_args).
    """
    sub_args = setup_parts[1:] if len(setup_parts) > 1 else []

    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return service_name, [], setup_parts

    try:
        with open(compose_path) as f:
            data = yaml.safe_load(f)
        services = data.get("services", {})

        if services.get(service_name, {}).get("entrypoint"):
            return service_name, [], sub_args

        cli_svc = None
        fallback_svc = None
        for svc_name, svc_def in services.items():
            if svc_name == service_name:
                continue
            if svc_def.get("entrypoint"):
                if "cli" in svc_name.lower():
                    cli_svc = svc_name
                    break
                elif not fallback_svc:
                    fallback_svc = svc_name

        if cli_svc:
            return cli_svc, [], sub_args
        if fallback_svc:
            return fallback_svc, [], sub_args

        cmd = services.get(service_name, {}).get("command", [])
        if isinstance(cmd, list) and len(cmd) >= 2:
            if cmd[0] in ("node", "python", "python3", "ruby", "java", "bun", "deno"):
                return service_name, ["--entrypoint", cmd[0]], [cmd[1]] + sub_args
    except Exception:
        pass

    return service_name, [], setup_parts
