from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path
from typing import Any

import yaml

from .models import MountEntry


# ----------------------------
# Image-aware rules
# ----------------------------

IMAGE_RULES = {
    "mariadb": {
        "/var/lib/mysql": "critical",
    },
    "mysql": {
        "/var/lib/mysql": "critical",
    },
    "postgres": {
        "/var/lib/postgresql/data": "critical",
    },
    "redis": {
        "/data": "critical",
    },
    "grafana": {
        "/var/lib/grafana": "critical",
    },
    "prometheus": {
        "/prometheus": "critical",
    },
    "influxdb": {
        "/var/lib/influxdb": "critical",
    },
    "nginx": {
        "/var/log/nginx": "optional",
    },
}


# ----------------------------
# Generic rules
# ----------------------------

CRITICAL_TARGETS = {
    "/config",
    "/data",
    "/var/lib/mysql",
    "/var/lib/mariadb",
    "/var/lib/postgresql/data",
    "/bitnami/postgresql",
    "/var/lib/redis",
    "/data/db",
    "/var/lib/grafana",
    "/var/lib/influxdb",
    "/var/lib/prometheus",
    "/etc/letsencrypt",
    "/acme.sh",
    "/app/data",
    "/srv",
}

REVIEW_TARGET_KEYWORDS = {
    "backup",
    "uploads",
    "media",
    "www",
    "html",
    "content",
    "storage",
    "files",
    "database",
    "db",
    "config",
}

SKIP_TARGET_PREFIXES = (
    "/tmp",
    "/var/tmp",
    "/run",
    "/var/run",
    "/dev",
)

SKIP_TARGET_EXACT = {
    "/var/log",
    "/var/log/nginx",
    "/logs",
    "/log",
    "/cache",
    "/tmp",
}


CLASS_PRIORITY = {
    "critical": 3,
    "review": 2,
    "optional": 1,
    "unknown": 0,
}


# ----------------------------
# Compose loader
# ----------------------------

def load_compose(compose_path: str | Path) -> dict[str, Any]:
    compose_file = Path(compose_path).expanduser().resolve()

    with compose_file.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}

    if not isinstance(data, dict):
        raise ValueError(f"Compose file did not parse as a mapping: {compose_file}")

    return data


# ----------------------------
# Docker helpers
# ----------------------------

def docker_available() -> bool:
    return shutil.which("docker") is not None


def run_docker_volume_inspect(volume_name: str) -> dict[str, Any] | None:
    if not docker_available():
        return None

    try:
        result = subprocess.run(
            ["docker", "volume", "inspect", volume_name],
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError:
        return None

    if result.returncode != 0:
        return None

    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError:
        return None

    if not isinstance(data, list) or not data:
        return None

    first = data[0]
    if not isinstance(first, dict):
        return None

    return first


# ----------------------------
# Volume resolution
# ----------------------------

def infer_project_name(compose_path: Path, compose_data: dict[str, Any]) -> str:
    top_level_name = compose_data.get("name")
    if isinstance(top_level_name, str) and top_level_name.strip():
        return top_level_name.strip()

    return compose_path.parent.name


def normalize_top_level_volume_name(
    volume_key: str,
    compose_data: dict[str, Any],
) -> tuple[str | None, bool]:
    volumes = compose_data.get("volumes", {})
    if not isinstance(volumes, dict):
        return None, False

    cfg = volumes.get(volume_key)
    if not isinstance(cfg, dict):
        return None, False

    explicit_name = cfg.get("name")
    if not isinstance(explicit_name, str) or not explicit_name.strip():
        explicit_name = None

    external = cfg.get("external", False)
    is_external = False

    if isinstance(external, bool):
        is_external = external
    elif isinstance(external, dict):
        is_external = True
        ext_name = external.get("name")
        if isinstance(ext_name, str) and ext_name.strip():
            explicit_name = ext_name.strip()

    return explicit_name, is_external


def build_volume_candidates(
    compose_name: str,
    compose_path: Path,
    compose_data: dict[str, Any],
) -> list[str]:
    project_name = infer_project_name(compose_path, compose_data)
    explicit_name, is_external = normalize_top_level_volume_name(compose_name, compose_data)

    candidates: list[str] = []

    if explicit_name:
        candidates.append(explicit_name)

    if is_external:
        candidates.append(compose_name)

    candidates.append(compose_name)
    candidates.append(f"{project_name}_{compose_name}")

    unique: list[str] = []
    seen: set[str] = set()

    for candidate in candidates:
        if candidate not in seen:
            unique.append(candidate)
            seen.add(candidate)

    return unique


def resolve_named_volume(
    compose_name: str,
    compose_path: Path,
    compose_data: dict[str, Any],
) -> tuple[Path | None, str]:
    if not docker_available():
        return None, "docker CLI not available"

    for candidate in build_volume_candidates(compose_name, compose_path, compose_data):
        inspected = run_docker_volume_inspect(candidate)
        if not inspected:
            continue

        mountpoint = inspected.get("Mountpoint")
        if isinstance(mountpoint, str) and mountpoint.strip():
            return Path(mountpoint), f"named volume '{compose_name}' -> docker volume '{candidate}'"

    return None, f"named volume '{compose_name}' could not be resolved"


# ----------------------------
# Parsing helpers
# ----------------------------

def _extract_image_name(image: str | None) -> str | None:
    if not image or not isinstance(image, str):
        return None

    if "/" in image:
        image = image.split("/")[-1]

    if ":" in image:
        image = image.split(":")[0]

    return image.lower()


def _is_bind_source(source: str) -> bool:
    return (
        source.startswith("/")
        or source.startswith("./")
        or source.startswith("../")
        or source.startswith("~/")
    )


def _normalize_bind_path(source: str, compose_file: Path) -> Path:
    path = Path(source).expanduser()
    if path.is_absolute():
        return path.resolve()
    return (compose_file.parent / path).resolve()


def _parse_volume_string(spec: str) -> dict[str, str | None]:
    parts = spec.split(":")

    if len(parts) == 1:
        return {
            "source": None,
            "target": parts[0],
            "mode": None,
            "kind": "anonymous",
        }

    source = parts[0]
    target = parts[1]
    mode = ":".join(parts[2:]) if len(parts) > 2 else None

    kind = "bind" if _is_bind_source(source) else "named"

    return {
        "source": source,
        "target": target,
        "mode": mode,
        "kind": kind,
    }


def _parse_volume_entry(entry: Any) -> dict[str, str | None]:
    if isinstance(entry, str):
        return _parse_volume_string(entry)

    if isinstance(entry, dict):
        entry_type = entry.get("type")
        source = entry.get("source") or entry.get("src")
        target = entry.get("target") or entry.get("dst") or entry.get("destination")

        if entry_type == "bind":
            kind = "bind"
        elif entry_type == "volume":
            kind = "named" if source else "anonymous"
        else:
            if isinstance(source, str) and source:
                kind = "bind" if _is_bind_source(source) else "named"
            else:
                kind = "anonymous"

        return {
            "source": source,
            "target": target,
            "mode": None,
            "kind": kind,
        }

    return {
        "source": None,
        "target": None,
        "mode": None,
        "kind": "unknown",
    }


# ----------------------------
# Classification logic
# ----------------------------

def _classify_target(target_path: str | None, image_name: str | None = None) -> tuple[str, str]:
    if not target_path:
        return "review", "missing container target path"

    if image_name and image_name in IMAGE_RULES:
        rules = IMAGE_RULES[image_name]
        if target_path in rules:
            level = rules[target_path]
            if level == "critical":
                return "critical", f"{image_name} rule for {target_path}"
            if level == "optional":
                return "optional", f"{image_name} rule for {target_path}"

    if target_path in CRITICAL_TARGETS:
        return "critical", f"critical target path {target_path}"

    if target_path in SKIP_TARGET_EXACT:
        return "optional", f"non-essential target path {target_path}"

    if target_path.startswith(SKIP_TARGET_PREFIXES):
        return "optional", f"ephemeral target path {target_path}"

    lowered = target_path.lower()
    for keyword in REVIEW_TARGET_KEYWORDS:
        if keyword in lowered:
            return "review", f"data-like target path {target_path} requires review"

    return "review", f"unknown target path {target_path}"


def _merge_reason(existing: str, new: str) -> str:
    if not existing:
        return new
    if not new or new == existing:
        return existing

    parts = [p.strip() for p in existing.split(" | ") if p.strip()]
    if new not in parts:
        parts.append(new)
    return " | ".join(parts)


def _prefer_entry(existing: MountEntry, new: MountEntry) -> MountEntry:
    existing_priority = CLASS_PRIORITY.get(existing.classification, 0)
    new_priority = CLASS_PRIORITY.get(new.classification, 0)

    if new_priority > existing_priority:
        preferred = new
        other = existing
    else:
        preferred = existing
        other = new

    preferred.reason = _merge_reason(preferred.reason, other.reason)

    if other.service and other.service not in preferred.reason:
        preferred.reason = _merge_reason(preferred.reason, f"also used by service={other.service} target={other.target}")

    preferred.exists = preferred.exists or other.exists
    return preferred


def _dedupe_entries(entries: list[MountEntry]) -> list[MountEntry]:
    deduped: dict[str, MountEntry] = {}

    for entry in entries:
        key = str(entry.source.resolve()) if entry.source.is_absolute() else str(entry.source)

        if key not in deduped:
            deduped[key] = entry
            continue

        deduped[key] = _prefer_entry(deduped[key], entry)

    return list(deduped.values())


def _make_entry(
    source: Path,
    service: str,
    target: str | None,
    classification: str,
    reason: str,
) -> MountEntry:
    return MountEntry(
        source=source,
        service=service,
        target=target or "unknown",
        classification=classification,
        reason=reason,
        exists=source.exists(),
    )


# ----------------------------
# Main classifier
# ----------------------------

def classify_compose(compose_path: str | Path) -> list[MountEntry]:
    compose_file = Path(compose_path).expanduser().resolve()
    compose_data = load_compose(compose_file)

    services = compose_data.get("services", {})
    if not isinstance(services, dict):
        return []

    entries: list[MountEntry] = []

    for service_name, service_cfg in services.items():
        if not isinstance(service_cfg, dict):
            continue

        raw_volumes = service_cfg.get("volumes", [])
        if not isinstance(raw_volumes, list):
            continue

        image_name = _extract_image_name(service_cfg.get("image"))

        for raw_entry in raw_volumes:
            parsed = _parse_volume_entry(raw_entry)
            source = parsed.get("source")
            target = parsed.get("target")
            kind = parsed.get("kind")

            if kind == "anonymous":
                entries.append(
                    MountEntry(
                        source=Path("/__anonymous_volume__"),
                        service=service_name,
                        target=target or "unknown",
                        classification="review",
                        reason="anonymous volume cannot be safely mapped to host path",
                        exists=False,
                    )
                )
                continue

            if kind == "bind" and isinstance(source, str):
                host_path = _normalize_bind_path(source, compose_file)
                classification, base_reason = _classify_target(target, image_name)
                reason = f"{base_reason}; bind mount source '{source}' -> '{host_path}'"

                entries.append(
                    _make_entry(
                        source=host_path,
                        service=service_name,
                        target=target,
                        classification=classification,
                        reason=reason,
                    )
                )
                continue

            if kind == "named" and isinstance(source, str):
                mountpoint, volume_reason = resolve_named_volume(source, compose_file, compose_data)

                if mountpoint is None:
                    entries.append(
                        MountEntry(
                            source=Path(f"/__named_volume_unresolved__/{source}"),
                            service=service_name,
                            target=target or "unknown",
                            classification="review",
                            reason=volume_reason,
                            exists=False,
                        )
                    )
                    continue

                classification, base_reason = _classify_target(target, image_name)
                reason = f"{base_reason}; {volume_reason}; mountpoint '{mountpoint}'"

                entries.append(
                    _make_entry(
                        source=mountpoint,
                        service=service_name,
                        target=target,
                        classification=classification,
                        reason=reason,
                    )
                )
                continue

            entries.append(
                MountEntry(
                    source=Path("/__unknown_volume__"),
                    service=service_name,
                    target=target or "unknown",
                    classification="review",
                    reason="unrecognized volume entry",
                    exists=False,
                )
            )

    return _dedupe_entries(entries)
