import json
import hashlib
import shlex
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

import yaml


def _normalize_metadata_value(value: object) -> str:
    if value is None:
        return ""
    normalized = str(value).strip()
    if normalized.lower() in {"", "none", "null"}:
        return ""
    return normalized


def _normalize_metadata_list(values: object) -> List[str]:
    if not isinstance(values, list):
        return []
    normalized: List[str] = []
    for value in values:
        item = _normalize_metadata_value(value)
        if item:
            normalized.append(item)
    return normalized


@dataclass
class OpenClawSkillInstallOption:
    id: str
    label: str
    kind: str = ""
    bins: List[str] = field(default_factory=list)
    formula: str = ""
    package: str = ""
    module: str = ""


@dataclass
class OpenClawSkill:
    key: str
    name: str
    description: str
    skill_dir: Path
    primary_env: Optional[str] = None
    required_env: List[str] = field(default_factory=list)
    required_bins: List[str] = field(default_factory=list)
    any_bins: List[str] = field(default_factory=list)
    install_options: List[OpenClawSkillInstallOption] = field(default_factory=list)


@dataclass
class OpenClawSkillCheck:
    key: str
    ready: bool
    missing_env: List[str] = field(default_factory=list)
    missing_bins: List[str] = field(default_factory=list)
    install_help: List[str] = field(default_factory=list)


def _parse_skill_frontmatter(skill_md: Path) -> Dict[str, object]:
    text = skill_md.read_text()
    if not text.startswith("---\n"):
        return {}
    try:
        _, frontmatter, _ = text.split("---", 2)
    except ValueError:
        return {}
    parsed = yaml.safe_load(frontmatter) or {}
    if not isinstance(parsed, dict):
        return {}
    metadata = parsed.get("metadata")
    if isinstance(metadata, str):
        try:
            parsed["metadata"] = json.loads(metadata)
        except Exception:
            pass
    return parsed


def _skill_dirs(install_dir: Path) -> List[Path]:
    candidates = [install_dir / "skills", install_dir / ".openclaw" / "skills"]
    workspace_dir = install_dir / "workspace" / "skills"
    candidates.append(workspace_dir)
    return [path for path in candidates if path.exists()]


def discover_openclaw_skills(install_dir: Path) -> List[OpenClawSkill]:
    discovered: Dict[str, OpenClawSkill] = {}
    for skills_root in _skill_dirs(install_dir):
        for skill_md in sorted(skills_root.glob("*/SKILL.md")):
            frontmatter = _parse_skill_frontmatter(skill_md)
            name = str(frontmatter.get("name") or skill_md.parent.name).strip()
            description = str(frontmatter.get("description") or name).strip()
            metadata = frontmatter.get("metadata") or {}
            if not isinstance(metadata, dict):
                metadata = {}
            openclaw_meta = metadata.get("openclaw") or {}
            if not isinstance(openclaw_meta, dict):
                openclaw_meta = {}
            requires = openclaw_meta.get("requires") or {}
            if not isinstance(requires, dict):
                requires = {}
            skill_key = str(openclaw_meta.get("skillKey") or name).strip()
            install_specs = openclaw_meta.get("install") or []
            install_options: List[OpenClawSkillInstallOption] = []
            if isinstance(install_specs, list):
                for idx, spec in enumerate(install_specs):
                    if not isinstance(spec, dict):
                        continue
                    install_options.append(
                        OpenClawSkillInstallOption(
                            id=str(spec.get("id") or f"option-{idx + 1}"),
                            label=str(spec.get("label") or spec.get("kind") or f"option-{idx + 1}"),
                            kind=_normalize_metadata_value(spec.get("kind")),
                            bins=_normalize_metadata_list(spec.get("bins", [])),
                            formula=_normalize_metadata_value(spec.get("formula")),
                            package=_normalize_metadata_value(spec.get("package")),
                            module=_normalize_metadata_value(spec.get("module")),
                        )
                    )
            primary_env_raw = openclaw_meta.get("primaryEnv")
            primary_env = _normalize_metadata_value(primary_env_raw)

            discovered[skill_key] = OpenClawSkill(
                key=skill_key,
                name=name,
                description=description,
                skill_dir=skill_md.parent,
                primary_env=primary_env or None,
                required_env=_normalize_metadata_list(requires.get("env", [])),
                required_bins=_normalize_metadata_list(requires.get("bins", [])),
                any_bins=_normalize_metadata_list(requires.get("anyBins", [])),
                install_options=install_options,
            )
    return sorted(discovered.values(), key=lambda skill: skill.key)


def check_openclaw_skill(
    skill: OpenClawSkill,
    env_lookup: Optional[Callable[[str], Optional[str]]] = None,
    bin_lookup: Optional[Callable[[str], bool]] = None,
) -> OpenClawSkillCheck:
    resolver = env_lookup or (lambda key: None)
    bin_resolver = bin_lookup or (lambda name: shutil.which(name) is not None)
    required_env = [
        env_name
        for env_name in dict.fromkeys([skill.primary_env, *skill.required_env])
        if env_name
    ]
    missing_env = [env_name for env_name in required_env if env_name and not resolver(env_name)]
    missing_bins = [bin_name for bin_name in skill.required_bins if not bin_resolver(bin_name)]
    if skill.any_bins and not any(bin_resolver(bin_name) for bin_name in skill.any_bins):
        missing_bins.extend(skill.any_bins)
    install_help = [option.label for option in skill.install_options]
    return OpenClawSkillCheck(
        key=skill.key,
        ready=not missing_env and not missing_bins,
        missing_env=missing_env,
        missing_bins=missing_bins,
        install_help=install_help,
    )


def _load_compose_services(install_dir: Path) -> Dict[str, Any]:
    for name in ("docker-compose.yml", "docker-compose.yaml"):
        compose_path = install_dir / name
        if compose_path.exists():
            data = yaml.safe_load(compose_path.read_text()) or {}
            services = data.get("services", {})
            return services if isinstance(services, dict) else {}
    return {}


def _runtime_service_name(install_dir: Path) -> str:
    services = _load_compose_services(install_dir)
    for candidate in ("openclaw-gateway", "gateway"):
        if candidate in services:
            return candidate
    return next(iter(services.keys()), "openclaw-gateway")


def _resolve_runtime_base_image(install_dir: Path, service_name: str, default_image: str) -> str:
    from armorer import docker_runner

    ps = docker_runner.compose_ps(
        cwd=install_dir,
        args=["--status", "running", "-q", service_name],
    )
    container_id = str(getattr(ps, "stdout", "") or "").strip()
    if not container_id:
        return default_image
    inspect = docker_runner.docker_container_inspect_format(container_id, "{{.Config.Image}}")
    image_ref = str(getattr(inspect, "stdout", "") or "").strip()
    if image_ref.startswith("sha256:"):
        return default_image
    return image_ref or default_image


def _resolve_runtime_user(install_dir: Path, service_name: str, base_image: str, service_config: Optional[Dict[str, Any]] = None) -> str:
    configured_user = ""
    if isinstance(service_config, dict):
        configured_user = _normalize_metadata_value(service_config.get("user"))
        if configured_user:
            return configured_user

    from armorer import docker_runner

    ps = docker_runner.compose_ps(
        cwd=install_dir,
        args=["--status", "running", "-q", service_name],
    )
    container_id = str(getattr(ps, "stdout", "") or "").strip()
    if container_id:
        inspect = docker_runner.docker_container_inspect_format(container_id, "{{.Config.User}}")
        runtime_user = _normalize_metadata_value(getattr(inspect, "stdout", ""))
        if runtime_user:
            return runtime_user

    inspect_image = docker_runner.docker_image_inspect_format(base_image, "{{.Config.User}}")
    return _normalize_metadata_value(getattr(inspect_image, "stdout", ""))


def runtime_openclaw_bin_exists(install_dir: Path, bin_name: str, service_name: Optional[str] = None) -> bool:
    service = service_name or _runtime_service_name(install_dir)
    shell_cmd = f"command -v {shlex.quote(bin_name)} >/dev/null 2>&1"
    from armorer import docker_runner

    ps = docker_runner.compose_ps(
        cwd=install_dir,
        args=["--status", "running", "-q", service],
    )
    if ps.stdout.strip():
        result = docker_runner.compose_exec(
            cwd=install_dir,
            service=service,
            exec_args=("/bin/sh", "-lc", shell_cmd),
        )
    else:
        result = docker_runner.compose_run(
            cwd=install_dir,
            service=service,
            entrypoint="/bin/sh",
            run_args=("-lc", shell_cmd),
        )
    return result.returncode == 0


def _select_runtime_install_option(skill: OpenClawSkill) -> Optional[OpenClawSkillInstallOption]:
    supported_order = ("apt", "npm", "go")
    for kind in supported_order:
        for option in skill.install_options:
            if option.kind == kind:
                if kind == "apt" and option.package:
                    return option
                if kind == "npm" and option.package:
                    return option
                if kind == "go" and option.module:
                    return option
    return None


def _runtime_overlay_paths(install_dir: Path) -> tuple[Path, Path]:
    armorer_dir = install_dir / ".armorer"
    return armorer_dir / "openclaw-runtime.Dockerfile", install_dir / "docker-compose.override.yml"


def _render_runtime_overlay(
    base_image: str,
    image_tag: str,
    apt_packages: List[str],
    npm_packages: List[str],
    go_modules: List[str],
    runtime_user: str = "",
) -> tuple[str, str]:
    dockerfile_lines: List[str] = []
    if go_modules:
        dockerfile_lines.extend(
            [
                "FROM golang:1.24-bookworm AS armorer_go_builder",
                "ENV CGO_ENABLED=0 GOBIN=/opt/armorer/bin",
                "RUN mkdir -p /opt/armorer/bin",
                "RUN " + " && ".join(f"go install {module}" for module in go_modules),
                "",
            ]
        )
    dockerfile_lines.append(f"FROM {base_image}")
    needs_root = bool(apt_packages or npm_packages)
    normalized_user = _normalize_metadata_value(runtime_user)
    if needs_root:
        dockerfile_lines.append("USER root")
    if apt_packages:
        dockerfile_lines.append(
            "RUN apt-get update && apt-get install -y --no-install-recommends "
            + " ".join(apt_packages)
            + " && rm -rf /var/lib/apt/lists/*"
        )
    if npm_packages:
        dockerfile_lines.append("RUN npm install -g " + " ".join(npm_packages))
    if go_modules:
        dockerfile_lines.append("COPY --from=armorer_go_builder /opt/armorer/bin/ /usr/local/bin/")
    if needs_root and normalized_user and normalized_user not in {"root", "0"}:
        dockerfile_lines.append(f"USER {normalized_user}")
    dockerfile_text = "\n".join(dockerfile_lines).rstrip() + "\n"

    override = {
        "services": {
            "openclaw-gateway": {
                "build": {
                    "context": ".",
                    "dockerfile": ".armorer/openclaw-runtime.Dockerfile",
                },
                "image": image_tag,
            },
            "openclaw-cli": {
                "image": image_tag,
            },
        }
    }
    return dockerfile_text, yaml.safe_dump(override, sort_keys=False)


def ensure_openclaw_skill_runtime_dependencies(
    install_dir: Path,
    selected_skills: List[OpenClawSkill],
) -> Dict[str, Any]:
    selected = [skill for skill in selected_skills if isinstance(skill, OpenClawSkill)]
    if not selected:
        return {"installed": [], "warnings": [], "blocked": []}

    missing_runtime: List[OpenClawSkill] = []
    warnings: List[str] = []
    blocked: List[str] = []
    for skill in selected:
        check = check_openclaw_skill(skill, env_lookup=lambda _name: "present", bin_lookup=lambda name: runtime_openclaw_bin_exists(install_dir, name))
        if check.missing_bins:
            missing_runtime.append(skill)

    if not missing_runtime:
        return {"installed": [], "warnings": [], "blocked": []}

    services = _load_compose_services(install_dir)
    gateway = services.get("openclaw-gateway", {}) if isinstance(services.get("openclaw-gateway"), dict) else {}
    compose_image = str(gateway.get("image") or "").strip() or "openclaw:local"
    base_image = _resolve_runtime_base_image(install_dir, "openclaw-gateway", compose_image)
    runtime_user = _resolve_runtime_user(install_dir, "openclaw-gateway", base_image, gateway)

    apt_packages: List[str] = []
    npm_packages: List[str] = []
    go_modules: List[str] = []
    installed_labels: List[str] = []
    for skill in missing_runtime:
        option = _select_runtime_install_option(skill)
        if not option:
            blocked.append(skill.key)
            warnings.append(
                f"Skill '{skill.key}' could not be auto-installed in the runtime container; supported runtime installers are apt, npm, and go."
            )
            continue
        if option.kind == "apt" and option.package:
            apt_packages.append(option.package)
            installed_labels.append(skill.key)
        elif option.kind == "npm" and option.package:
            npm_packages.append(option.package)
            installed_labels.append(skill.key)
        elif option.kind == "go" and option.module:
            go_modules.append(option.module)
            installed_labels.append(skill.key)
        else:
            blocked.append(skill.key)
            warnings.append(f"Skill '{skill.key}' does not expose a usable runtime installer.")

    if not apt_packages and not npm_packages and not go_modules:
        return {"installed": [], "warnings": warnings, "blocked": blocked}

    key_parts = [base_image, *sorted(apt_packages), *sorted(npm_packages), *sorted(go_modules)]
    image_hash = hashlib.sha256("|".join(key_parts).encode()).hexdigest()[:12]
    image_tag = f"openclaw:armorer-{image_hash}"
    dockerfile_path, override_path = _runtime_overlay_paths(install_dir)
    dockerfile_path.parent.mkdir(parents=True, exist_ok=True)
    dockerfile_text, override_text = _render_runtime_overlay(
        base_image=base_image,
        image_tag=image_tag,
        apt_packages=sorted(set(apt_packages)),
        npm_packages=sorted(set(npm_packages)),
        go_modules=sorted(set(go_modules)),
        runtime_user=runtime_user,
    )
    dockerfile_path.write_text(dockerfile_text)
    override_path.write_text(override_text)

    from armorer import docker_runner

    build_result = docker_runner.docker_build_file(
        context=install_dir,
        tag=image_tag,
        dockerfile=dockerfile_path.relative_to(install_dir),
    )
    if build_result.returncode != 0:
        raise subprocess.CalledProcessError(
            build_result.returncode, list(build_result.args), output=build_result.stdout, stderr=build_result.stderr
        )
    up_result = docker_runner.compose_up(
        cwd=install_dir,
        detach=True,
        force_recreate=True,
    )
    if up_result.returncode != 0:
        raise subprocess.CalledProcessError(
            up_result.returncode, list(up_result.args), output=up_result.stdout, stderr=up_result.stderr
        )

    for skill in selected:
        check = check_openclaw_skill(skill, env_lookup=lambda _name: "present", bin_lookup=lambda name: runtime_openclaw_bin_exists(install_dir, name))
        if check.missing_bins and skill.key not in blocked:
            blocked.append(skill.key)
            warnings.append(
                f"Skill '{skill.key}' is still missing runtime binaries after installation: {', '.join(check.missing_bins)}."
            )

    return {
        "installed": installed_labels,
        "warnings": warnings,
        "blocked": blocked,
        "image_tag": image_tag,
        "dockerfile": str(dockerfile_path),
        "override_file": str(override_path),
    }
