from __future__ import annotations

import dataclasses
import subprocess
from pathlib import Path
from typing import (
    Any,
    Callable,
    Dict,
    Literal,
    Mapping,
    Optional,
    Protocol,
    Sequence,
    cast,
)

from ..manifest import (
    ManifestAgentWorkspace,
    ManifestRepo,
    normalize_manifest_destination,
)
from ..workspace import workspace_id_for_path
from .utils import subprocess_env


class Destination(Protocol):
    @property
    def kind(self) -> str: ...

    def to_dict(self) -> dict[str, object]: ...


@dataclasses.dataclass(frozen=True)
class LocalDestination:
    kind: str = "local"

    def to_dict(self) -> dict[str, object]:
        return {"kind": self.kind}


@dataclasses.dataclass(frozen=True)
class DockerMount:
    source: str
    target: str
    read_only: bool = False

    def to_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "source": self.source,
            "target": self.target,
        }
        if self.read_only:
            payload["read_only"] = True
        return payload


DockerProfile = Literal["full-dev"]


@dataclasses.dataclass(frozen=True)
class DockerDestination:
    image: str
    container_name: Optional[str] = None
    mounts: tuple[DockerMount, ...] = ()
    env_passthrough: tuple[str, ...] = ()
    workdir: Optional[str] = None
    profile: Optional[DockerProfile] = None
    env: Mapping[str, str] = dataclasses.field(default_factory=dict)
    extra: Mapping[str, object] = dataclasses.field(default_factory=dict)
    kind: str = "docker"

    def to_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {"kind": self.kind, "image": self.image}
        if self.container_name:
            payload["container_name"] = self.container_name
        if self.mounts:
            payload["mounts"] = [item.to_dict() for item in self.mounts]
        if self.env_passthrough:
            payload["env_passthrough"] = list(self.env_passthrough)
        if self.workdir:
            payload["workdir"] = self.workdir
        if self.profile:
            payload["profile"] = self.profile
        if self.env:
            payload["env"] = dict(self.env)
        payload.update(self.extra)
        return payload


@dataclasses.dataclass(frozen=True)
class DestinationParseResult:
    destination: Destination
    valid: bool
    errors: tuple[str, ...] = ()


@dataclasses.dataclass(frozen=True)
class DestinationWriteValidationResult:
    normalized_destination: Optional[Dict[str, Any]]
    valid: bool
    errors: tuple[str, ...] = ()


@dataclasses.dataclass(frozen=True)
class DestinationResolution:
    destination: Destination
    source: str
    issues: tuple[str, ...] = ()

    def to_dict(self) -> dict[str, object]:
        return self.destination.to_dict()


RunFn = Callable[..., subprocess.CompletedProcess[str]]


@dataclasses.dataclass(frozen=True)
class DockerReadiness:
    binary_available: bool
    daemon_reachable: bool
    detail: str = ""

    @property
    def ready(self) -> bool:
        return self.binary_available and self.daemon_reachable


def _run_docker_probe_command(
    *,
    docker_binary: str,
    args: Sequence[str],
    run_fn: RunFn,
    timeout_seconds: float,
) -> subprocess.CompletedProcess[str]:
    return run_fn(
        [docker_binary, *[str(part) for part in args]],
        capture_output=True,
        text=True,
        check=False,
        env=subprocess_env(),
        timeout=timeout_seconds,
    )


def probe_docker_readiness(
    *,
    docker_binary: str = "docker",
    run_fn: RunFn = subprocess.run,
    timeout_seconds: float = 10.0,
) -> DockerReadiness:
    try:
        version_proc = _run_docker_probe_command(
            docker_binary=docker_binary,
            args=["--version"],
            run_fn=run_fn,
            timeout_seconds=timeout_seconds,
        )
    except FileNotFoundError:
        return DockerReadiness(
            binary_available=False,
            daemon_reachable=False,
            detail=f"Docker binary '{docker_binary}' not found",
        )

    if version_proc.returncode != 0:
        details = (version_proc.stderr or version_proc.stdout or "").strip()
        return DockerReadiness(
            binary_available=False,
            daemon_reachable=False,
            detail=details or "docker --version failed",
        )

    info_proc = _run_docker_probe_command(
        docker_binary=docker_binary,
        args=["info", "--format", "{{.ServerVersion}}"],
        run_fn=run_fn,
        timeout_seconds=timeout_seconds,
    )
    if info_proc.returncode != 0:
        details = (info_proc.stderr or info_proc.stdout or "").strip()
        return DockerReadiness(
            binary_available=True,
            daemon_reachable=False,
            detail=details or "docker daemon is unreachable",
        )

    server_version = (info_proc.stdout or "").strip()
    detail = (
        f"docker daemon reachable (server={server_version})"
        if server_version
        else "docker daemon reachable"
    )
    return DockerReadiness(
        binary_available=True,
        daemon_reachable=True,
        detail=detail,
    )


def default_local_destination() -> dict[str, object]:
    return LocalDestination().to_dict()


def default_car_docker_container_name(repo_root: Path) -> str:
    return f"car-ws-{workspace_id_for_path(repo_root)}"


def parse_destination_config(
    value: object,
    *,
    context: str = "destination",
) -> DestinationParseResult:
    normalized = normalize_manifest_destination(value)
    if normalized is None:
        return DestinationParseResult(
            destination=LocalDestination(),
            valid=False,
            errors=(f"{context}: expected a mapping with non-empty 'kind'",),
        )

    kind = str(normalized.get("kind", "")).strip().lower()
    if kind == "local":
        return DestinationParseResult(destination=LocalDestination(), valid=True)
    if kind != "docker":
        return DestinationParseResult(
            destination=LocalDestination(),
            valid=False,
            errors=(f"{context}: unsupported destination kind '{kind}'",),
        )

    errors: list[str] = []
    image = normalized.get("image")
    if not isinstance(image, str) or not image.strip():
        errors.append(f"{context}: docker destination requires non-empty 'image'")
    else:
        image = image.strip()

    container_name = normalized.get("container_name")
    if container_name is not None:
        if not isinstance(container_name, str) or not container_name.strip():
            errors.append(
                f"{context}: optional 'container_name' must be a non-empty string"
            )
            container_name = None
        else:
            container_name = container_name.strip()

    mounts: tuple[DockerMount, ...] = ()
    mounts_raw = normalized.get("mounts")
    if mounts_raw is not None:
        if not isinstance(mounts_raw, list):
            errors.append(f"{context}: optional 'mounts' must be a list")
        else:
            parsed_mounts: list[DockerMount] = []
            for idx, mount in enumerate(mounts_raw):
                if not isinstance(mount, dict):
                    errors.append(
                        f"{context}: mounts[{idx}] must be an object with source/target"
                    )
                    continue
                source = mount.get("source")
                target = mount.get("target")
                if not isinstance(source, str) or not source.strip():
                    errors.append(
                        f"{context}: mounts[{idx}].source must be a non-empty string"
                    )
                    continue
                if not isinstance(target, str) or not target.strip():
                    errors.append(
                        f"{context}: mounts[{idx}].target must be a non-empty string"
                    )
                    continue
                parsed_read_only = False
                raw_read_only = mount.get("read_only")
                if raw_read_only is None and "readOnly" in mount:
                    raw_read_only = mount.get("readOnly")
                if raw_read_only is None and "readonly" in mount:
                    raw_read_only = mount.get("readonly")
                if raw_read_only is not None:
                    if not isinstance(raw_read_only, bool):
                        errors.append(
                            f"{context}: mounts[{idx}].read_only must be a boolean"
                        )
                        continue
                    parsed_read_only = raw_read_only
                parsed_mounts.append(
                    DockerMount(
                        source=source.strip(),
                        target=target.strip(),
                        read_only=parsed_read_only,
                    )
                )
            mounts = tuple(parsed_mounts)

    env_passthrough: tuple[str, ...] = ()
    env_raw = normalized.get("env_passthrough")
    if env_raw is not None:
        if not isinstance(env_raw, list):
            errors.append(f"{context}: optional 'env_passthrough' must be a list")
        else:
            parsed_env: list[str] = []
            for idx, item in enumerate(env_raw):
                if not isinstance(item, str) or not item.strip():
                    errors.append(
                        f"{context}: env_passthrough[{idx}] must be a non-empty string"
                    )
                    continue
                parsed_env.append(item.strip())
            env_passthrough = tuple(parsed_env)

    workdir = normalized.get("workdir")
    if workdir is not None:
        if not isinstance(workdir, str) or not workdir.strip():
            errors.append(f"{context}: optional 'workdir' must be a non-empty string")
            workdir = None
        else:
            workdir = workdir.strip()

    profile = normalized.get("profile")
    if profile is not None:
        if not isinstance(profile, str) or not profile.strip():
            errors.append(f"{context}: optional 'profile' must be a non-empty string")
            profile = None
        else:
            profile = profile.strip().lower()
            if profile != "full-dev":
                errors.append(f"{context}: unsupported docker profile '{profile}'")

    explicit_env: Optional[dict[str, str]] = None
    env_map_raw = normalized.get("env")
    if env_map_raw is not None:
        if not isinstance(env_map_raw, dict):
            errors.append(f"{context}: optional 'env' must be an object")
        else:
            parsed_env_map: dict[str, str] = {}
            for raw_key, raw_value in env_map_raw.items():
                if not isinstance(raw_key, str) or not raw_key.strip():
                    errors.append(f"{context}: env keys must be non-empty strings")
                    continue
                if not isinstance(raw_value, str):
                    errors.append(f"{context}: env[{raw_key!r}] must be a string value")
                    continue
                parsed_env_map[raw_key.strip()] = raw_value
            explicit_env = parsed_env_map

    if errors:
        return DestinationParseResult(
            destination=LocalDestination(),
            valid=False,
            errors=tuple(errors),
        )

    extra: dict[str, object] = {
        key: val
        for key, val in normalized.items()
        if key
        not in {
            "kind",
            "image",
            "container_name",
            "mounts",
            "env_passthrough",
            "workdir",
            "profile",
            "env",
        }
    }
    image_value = image if isinstance(image, str) else ""
    return DestinationParseResult(
        destination=DockerDestination(
            image=image_value,
            container_name=container_name,
            mounts=mounts,
            env_passthrough=env_passthrough,
            workdir=workdir,
            profile=cast(Optional[DockerProfile], profile),
            env=explicit_env or {},
            extra=extra,
        ),
        valid=True,
    )


def validate_destination_write_payload(
    value: Any,
    *,
    context: str = "destination",
) -> DestinationWriteValidationResult:
    parsed = parse_destination_config(value, context=context)
    if not parsed.valid:
        return DestinationWriteValidationResult(
            normalized_destination=None,
            valid=False,
            errors=tuple(parsed.errors),
        )
    normalized = normalize_manifest_destination(parsed.destination.to_dict())
    if normalized is None:
        return DestinationWriteValidationResult(
            normalized_destination=None,
            valid=False,
            errors=(f"{context}: invalid destination payload",),
        )
    return DestinationWriteValidationResult(
        normalized_destination=normalized,
        valid=True,
        errors=(),
    )


def resolve_effective_repo_destination(
    repo: ManifestRepo,
    repos_by_id: Mapping[str, ManifestRepo],
) -> DestinationResolution:
    issues: list[str] = []

    if repo.destination is not None:
        own = parse_destination_config(
            repo.destination, context=f"repo '{repo.id}' destination"
        )
        if own.valid:
            return DestinationResolution(destination=own.destination, source="repo")
        issues.extend(own.errors)

    if repo.kind == "worktree" and repo.worktree_of:
        parent = repos_by_id.get(repo.worktree_of)
        if parent and parent.destination is not None:
            inherited = parse_destination_config(
                parent.destination,
                context=f"base repo '{parent.id}' destination",
            )
            if inherited.valid:
                return DestinationResolution(
                    destination=inherited.destination,
                    source="base",
                    issues=tuple(issues),
                )
            issues.extend(inherited.errors)

    return DestinationResolution(
        destination=LocalDestination(),
        source="default",
        issues=tuple(issues),
    )


def resolve_effective_agent_workspace_destination(
    workspace: ManifestAgentWorkspace,
) -> DestinationResolution:
    issues: list[str] = []

    if workspace.destination is not None:
        own = parse_destination_config(
            workspace.destination,
            context=f"agent workspace '{workspace.id}' destination",
        )
        if own.valid:
            return DestinationResolution(
                destination=own.destination,
                source="agent_workspace",
            )
        issues.extend(own.errors)

    return DestinationResolution(
        destination=LocalDestination(),
        source="default",
        issues=tuple(issues),
    )


__all__ = [
    "Destination",
    "DestinationParseResult",
    "DestinationResolution",
    "DestinationWriteValidationResult",
    "DockerDestination",
    "DockerMount",
    "DockerReadiness",
    "LocalDestination",
    "default_car_docker_container_name",
    "default_local_destination",
    "parse_destination_config",
    "probe_docker_readiness",
    "resolve_effective_agent_workspace_destination",
    "resolve_effective_repo_destination",
    "validate_destination_write_payload",
]
