"""ZFS backend for Copy-on-Write filesystem support."""

from __future__ import annotations

import shutil
import subprocess
from datetime import datetime
from pathlib import Path

from gaol.cow.backends.base import CoWBackendBase
from gaol.cow.exceptions import (
    BackendNotAvailableError,
    CoWError,
    DatasetExistsError,
    InvalidMountPointError,
    SendReceiveError,
    SnapshotExistsError,
    SnapshotHasClonesError,
)
from gaol.cow.models import (
    CoWBackendType,
    DatasetConfig,
    DatasetState,
    PoolConfig,
    PoolState,
    PoolStatus,
    Snapshot,
)


def _run_zfs(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
    """Run a zfs command."""
    cmd = ["zfs"] + args
    return subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        check=check,
    )


def _run_zpool(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
    """Run a zpool command."""
    cmd = ["zpool"] + args
    return subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        check=check,
    )


class ZfsBackend(CoWBackendBase):
    """ZFS dataset-based CoW backend."""

    @property
    def name(self) -> str:
        return "zfs"

    def is_available(self) -> bool:
        """Check if ZFS tools are available."""
        return shutil.which("zfs") is not None and shutil.which("zpool") is not None

    def _check_available(self) -> None:
        """Raise if ZFS is not available."""
        if not self.is_available():
            raise BackendNotAvailableError("zfs", "zfs utilities not installed")

    def validate_mount_point(self, path: Path) -> bool:
        """Check if path is a ZFS filesystem mount."""
        self._check_available()

        if not path.exists() or not path.is_dir():
            return False

        try:
            # Check if any zfs dataset is mounted at this path
            result = _run_zfs(["list", "-H", "-o", "mountpoint"], check=False)
            if result.returncode == 0:
                mount_points = result.stdout.strip().split("\n")
                return str(path) in mount_points
        except Exception:
            pass

        return False

    def _get_pool_name(self, mount_point: Path) -> str | None:
        """Get the ZFS pool name for a mount point."""
        try:
            result = _run_zfs([
                "list", "-H", "-o", "name,mountpoint"
            ], check=False)
            if result.returncode == 0:
                for line in result.stdout.strip().split("\n"):
                    parts = line.split("\t")
                    if len(parts) >= 2 and parts[1] == str(mount_point):
                        # Return just the pool name (first part before /)
                        return parts[0].split("/")[0]
        except Exception:
            pass
        return None

    def _get_dataset_name(self, pool: PoolState, name: str | None = None) -> str:
        """Get the full ZFS dataset name."""
        # Extract the base zpool from mount_point
        zpool = self._get_pool_name(pool.mount_point)
        if not zpool:
            zpool = pool.name

        base = f"{zpool}/gaol"
        if name:
            return f"{base}/{name}"
        return base

    def _get_snapshot_name(self, pool: PoolState, dataset: str, snapshot: str) -> str:
        """Get the full ZFS snapshot name."""
        dataset_name = self._get_dataset_name(pool, dataset)
        return f"{dataset_name}@{snapshot}"

    def _parse_size(self, size_str: str) -> int:
        """Parse ZFS size string to bytes."""
        if not size_str or size_str == "-":
            return 0

        multipliers = {
            "K": 1024,
            "M": 1024**2,
            "G": 1024**3,
            "T": 1024**4,
        }

        try:
            if size_str[-1] in multipliers:
                return int(float(size_str[:-1]) * multipliers[size_str[-1]])
            return int(size_str)
        except (ValueError, IndexError):
            return 0

    def _get_zpool_info(self, zpool: str) -> dict[str, int]:
        """Get zpool usage information."""
        try:
            result = _run_zpool([
                "list", "-H", "-o", "size,alloc,free", zpool
            ], check=False)
            if result.returncode == 0:
                parts = result.stdout.strip().split("\t")
                if len(parts) >= 3:
                    return {
                        "total": self._parse_size(parts[0]),
                        "used": self._parse_size(parts[1]),
                        "free": self._parse_size(parts[2]),
                    }
        except Exception:
            pass
        return {"total": 0, "used": 0, "free": 0}

    def _get_dataset_props(self, dataset_name: str) -> dict[str, str]:
        """Get ZFS dataset properties."""
        try:
            result = _run_zfs([
                "get", "-H", "-o", "property,value",
                "used,referenced,quota,compression,creation",
                dataset_name
            ], check=False)
            if result.returncode == 0:
                props = {}
                for line in result.stdout.strip().split("\n"):
                    parts = line.split("\t")
                    if len(parts) >= 2:
                        props[parts[0]] = parts[1]
                return props
        except Exception:
            pass
        return {}

    # Pool operations

    def init_pool(self, config: PoolConfig) -> PoolState:
        """Initialize a ZFS pool."""
        self._check_available()

        if not self.validate_mount_point(config.mount_point):
            raise InvalidMountPointError(str(config.mount_point), "zfs")

        zpool = self._get_pool_name(config.mount_point)
        if not zpool:
            raise InvalidMountPointError(
                str(config.mount_point),
                "zfs (could not determine pool name)"
            )

        # Create gaol dataset
        gaol_dataset = f"{zpool}/gaol"

        result = _run_zfs(["list", "-H", gaol_dataset], check=False)
        if result.returncode != 0:
            try:
                create_args = ["create"]
                if config.compression:
                    create_args.extend(["-o", f"compression={config.compression}"])
                create_args.append(gaol_dataset)
                _run_zfs(create_args)
            except subprocess.CalledProcessError as e:
                raise CoWError(f"Failed to create dataset: {e.stderr}") from e

        # Get mount point for the gaol dataset
        result = _run_zfs(["get", "-H", "-o", "value", "mountpoint", gaol_dataset])
        gaol_path = Path(result.stdout.strip())

        # Get pool info
        pool_info = self._get_zpool_info(zpool)

        return PoolState(
            name=config.name,
            backend=CoWBackendType.ZFS,
            status=PoolStatus.ONLINE,
            mount_point=config.mount_point,
            gaol_path=gaol_path,
            total_bytes=pool_info["total"],
            used_bytes=pool_info["used"],
            free_bytes=pool_info["free"],
            compression=config.compression,
            created_at=datetime.now(),
        )

    def get_pool_info(self, state: PoolState) -> PoolState:
        """Get updated pool information."""
        self._check_available()

        zpool = self._get_pool_name(state.mount_point)
        if not zpool:
            return PoolState(
                name=state.name,
                backend=state.backend,
                status=PoolStatus.ERROR,
                mount_point=state.mount_point,
                gaol_path=state.gaol_path,
                total_bytes=0,
                used_bytes=0,
                free_bytes=0,
                compression=state.compression,
                created_at=state.created_at,
            )

        pool_info = self._get_zpool_info(zpool)

        # Check pool health
        status = PoolStatus.ONLINE
        try:
            result = _run_zpool(["status", "-x", zpool], check=False)
            if "degraded" in result.stdout.lower():
                status = PoolStatus.DEGRADED
            elif "error" in result.stdout.lower() or "faulted" in result.stdout.lower():
                status = PoolStatus.ERROR
        except Exception:
            pass

        return PoolState(
            name=state.name,
            backend=state.backend,
            status=status,
            mount_point=state.mount_point,
            gaol_path=state.gaol_path,
            total_bytes=pool_info["total"],
            used_bytes=pool_info["used"],
            free_bytes=pool_info["free"],
            compression=state.compression,
            created_at=state.created_at,
        )

    def destroy_pool(self, state: PoolState) -> None:
        """Destroy a ZFS pool (gaol dataset)."""
        self._check_available()

        zpool = self._get_pool_name(state.mount_point)
        if not zpool:
            return

        gaol_dataset = f"{zpool}/gaol"

        try:
            # Destroy recursively
            _run_zfs(["destroy", "-r", gaol_dataset], check=False)
        except Exception:
            pass

    # Dataset operations

    def create_dataset(self, pool: PoolState, config: DatasetConfig) -> DatasetState:
        """Create a new ZFS dataset."""
        self._check_available()

        dataset_name = self._get_dataset_name(pool, config.name)

        # Check if exists
        result = _run_zfs(["list", "-H", dataset_name], check=False)
        if result.returncode == 0:
            raise DatasetExistsError(pool.name, config.name)

        try:
            create_args = ["create"]
            if config.quota_mb:
                create_args.extend(["-o", f"quota={config.quota_mb}M"])
            if config.compression:
                create_args.extend(["-o", f"compression={config.compression}"])
            create_args.append(dataset_name)
            _run_zfs(create_args)
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to create dataset: {e.stderr}") from e

        # Get mount point
        result = _run_zfs(["get", "-H", "-o", "value", "mountpoint", dataset_name])
        path = Path(result.stdout.strip())

        props = self._get_dataset_props(dataset_name)

        return DatasetState(
            name=config.name,
            pool=pool.name,
            path=path,
            quota_mb=config.quota_mb,
            used_bytes=self._parse_size(props.get("used", "0")),
            referenced_bytes=self._parse_size(props.get("referenced", "0")),
            compression=config.compression or pool.compression,
            created_at=datetime.now(),
            snapshots=[],
        )

    def get_dataset_info(self, pool: PoolState, state: DatasetState) -> DatasetState:
        """Get updated dataset information."""
        self._check_available()

        dataset_name = self._get_dataset_name(pool, state.name)
        props = self._get_dataset_props(dataset_name)

        # List snapshots
        snapshots = []
        try:
            result = _run_zfs([
                "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset_name
            ], check=False)
            if result.returncode == 0:
                for line in result.stdout.strip().split("\n"):
                    if "@" in line and line.startswith(dataset_name + "@"):
                        snap_name = line.split("@")[1]
                        snapshots.append(snap_name)
        except Exception:
            pass

        # Parse quota
        quota_mb = None
        quota_str = props.get("quota", "none")
        if quota_str and quota_str != "none":
            quota_bytes = self._parse_size(quota_str)
            if quota_bytes > 0:
                quota_mb = quota_bytes // (1024 * 1024)

        return DatasetState(
            name=state.name,
            pool=state.pool,
            path=state.path,
            quota_mb=quota_mb,
            used_bytes=self._parse_size(props.get("used", "0")),
            referenced_bytes=self._parse_size(props.get("referenced", "0")),
            compression=props.get("compression", state.compression),
            created_at=state.created_at,
            snapshots=snapshots,
        )

    def destroy_dataset(self, pool: PoolState, state: DatasetState) -> None:
        """Destroy a dataset."""
        self._check_available()

        dataset_name = self._get_dataset_name(pool, state.name)

        try:
            # Destroy recursively (including snapshots)
            _run_zfs(["destroy", "-r", dataset_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to destroy dataset: {e.stderr}") from e

    def set_quota(self, pool: PoolState, state: DatasetState, quota_mb: int | None) -> DatasetState:
        """Set or remove quota on a dataset."""
        self._check_available()

        dataset_name = self._get_dataset_name(pool, state.name)

        try:
            if quota_mb:
                _run_zfs(["set", f"quota={quota_mb}M", dataset_name])
            else:
                _run_zfs(["set", "quota=none", dataset_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to set quota: {e.stderr}") from e

        return DatasetState(
            name=state.name,
            pool=state.pool,
            path=state.path,
            quota_mb=quota_mb,
            used_bytes=state.used_bytes,
            referenced_bytes=state.referenced_bytes,
            compression=state.compression,
            created_at=state.created_at,
            snapshots=state.snapshots,
        )

    # Snapshot operations

    def create_snapshot(
        self, pool: PoolState, dataset: DatasetState, name: str
    ) -> Snapshot:
        """Create a snapshot."""
        self._check_available()

        snapshot_name = self._get_snapshot_name(pool, dataset.name, name)

        # Check if exists
        result = _run_zfs(["list", "-H", "-t", "snapshot", snapshot_name], check=False)
        if result.returncode == 0:
            raise SnapshotExistsError(pool.name, dataset.name, name)

        try:
            _run_zfs(["snapshot", snapshot_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to create snapshot: {e.stderr}") from e

        # Get snapshot properties
        props = self._get_dataset_props(snapshot_name)

        return Snapshot(
            name=name,
            dataset=dataset.name,
            pool=pool.name,
            created_at=datetime.now(),
            used_bytes=self._parse_size(props.get("used", "0")),
            referenced_bytes=self._parse_size(props.get("referenced", "0")),
            readonly=True,
        )

    def list_snapshots(
        self, pool: PoolState, dataset: DatasetState
    ) -> list[Snapshot]:
        """List all snapshots of a dataset."""
        self._check_available()

        dataset_name = self._get_dataset_name(pool, dataset.name)
        snapshots = []

        try:
            result = _run_zfs([
                "list", "-H", "-t", "snapshot",
                "-o", "name,used,refer,creation",
                "-r", dataset_name
            ], check=False)

            if result.returncode == 0:
                for line in result.stdout.strip().split("\n"):
                    if not line or "@" not in line:
                        continue
                    parts = line.split("\t")
                    if len(parts) >= 4 and parts[0].startswith(dataset_name + "@"):
                        snap_name = parts[0].split("@")[1]
                        # Parse creation time
                        try:
                            created = datetime.strptime(
                                parts[3].strip(),
                                "%a %b %d %H:%M %Y"
                            )
                        except ValueError:
                            created = datetime.now()

                        snapshots.append(Snapshot(
                            name=snap_name,
                            dataset=dataset.name,
                            pool=pool.name,
                            created_at=created,
                            used_bytes=self._parse_size(parts[1]),
                            referenced_bytes=self._parse_size(parts[2]),
                            readonly=True,
                        ))
        except Exception:
            pass

        return snapshots

    def delete_snapshot(
        self, pool: PoolState, dataset: DatasetState, snapshot: Snapshot
    ) -> None:
        """Delete a snapshot."""
        self._check_available()

        snapshot_name = self._get_snapshot_name(pool, dataset.name, snapshot.name)

        # Check for clones
        try:
            result = _run_zfs([
                "get", "-H", "-o", "value", "clones", snapshot_name
            ], check=False)
            if result.returncode == 0:
                clones = result.stdout.strip()
                if clones and clones != "-":
                    raise SnapshotHasClonesError(pool.name, dataset.name, snapshot.name)
        except SnapshotHasClonesError:
            raise
        except Exception:
            pass

        try:
            _run_zfs(["destroy", snapshot_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to delete snapshot: {e.stderr}") from e

    def rollback(
        self, pool: PoolState, dataset: DatasetState, snapshot: Snapshot
    ) -> DatasetState:
        """Rollback a dataset to a snapshot."""
        self._check_available()

        snapshot_name = self._get_snapshot_name(pool, dataset.name, snapshot.name)

        try:
            # Note: This destroys all snapshots more recent than the target
            _run_zfs(["rollback", "-r", snapshot_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to rollback: {e.stderr}") from e

        return self.get_dataset_info(pool, dataset)

    # Clone operations

    def create_clone(
        self, pool: PoolState, snapshot: Snapshot, name: str
    ) -> DatasetState:
        """Create a writable clone from a snapshot."""
        self._check_available()

        snapshot_name = self._get_snapshot_name(pool, snapshot.dataset, snapshot.name)
        clone_name = self._get_dataset_name(pool, name)

        # Check if clone name already exists
        result = _run_zfs(["list", "-H", clone_name], check=False)
        if result.returncode == 0:
            raise DatasetExistsError(pool.name, name)

        try:
            _run_zfs(["clone", snapshot_name, clone_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to create clone: {e.stderr}") from e

        # Get mount point
        result = _run_zfs(["get", "-H", "-o", "value", "mountpoint", clone_name])
        path = Path(result.stdout.strip())

        props = self._get_dataset_props(clone_name)

        return DatasetState(
            name=name,
            pool=pool.name,
            path=path,
            quota_mb=None,
            used_bytes=self._parse_size(props.get("used", "0")),
            referenced_bytes=self._parse_size(props.get("referenced", "0")),
            compression=props.get("compression", pool.compression),
            created_at=datetime.now(),
            snapshots=[],
        )

    def promote_clone(
        self, pool: PoolState, clone: DatasetState
    ) -> DatasetState:
        """Promote a clone to independent dataset."""
        self._check_available()

        clone_name = self._get_dataset_name(pool, clone.name)

        try:
            _run_zfs(["promote", clone_name])
        except subprocess.CalledProcessError as e:
            raise CoWError(f"Failed to promote clone: {e.stderr}") from e

        return self.get_dataset_info(pool, clone)

    # Send/Receive operations

    def send(
        self,
        pool: PoolState,
        dataset: DatasetState,
        snapshot: Snapshot,
        dest: Path,
        base_snapshot: Snapshot | None = None,
    ) -> Path:
        """Send a snapshot to a file."""
        self._check_available()

        snapshot_name = self._get_snapshot_name(pool, dataset.name, snapshot.name)

        try:
            cmd = ["zfs", "send"]

            if base_snapshot:
                base_name = self._get_snapshot_name(
                    pool, dataset.name, base_snapshot.name
                )
                cmd.extend(["-i", base_name])

            cmd.append(snapshot_name)

            # Pipe to file
            with open(dest, "wb") as f:
                subprocess.run(
                    cmd,
                    stdout=f,
                    stderr=subprocess.PIPE,
                    check=True,
                )

            return dest

        except subprocess.CalledProcessError as e:
            raise SendReceiveError("send", e.stderr.decode()) from e

    def receive(
        self,
        pool: PoolState,
        source: Path,
        name: str | None = None,
    ) -> DatasetState:
        """Receive a snapshot stream into a new dataset."""
        self._check_available()

        if not source.exists():
            raise SendReceiveError("receive", f"Source file not found: {source}")

        # Determine destination dataset name
        dataset_name = self._get_dataset_name(pool, name or source.stem)

        try:
            with open(source, "rb") as f:
                subprocess.run(
                    ["zfs", "receive", "-F", dataset_name],
                    stdin=f,
                    capture_output=True,
                    check=True,
                )

            # Get mount point
            result = _run_zfs([
                "get", "-H", "-o", "value", "mountpoint", dataset_name
            ])
            path = Path(result.stdout.strip())

            props = self._get_dataset_props(dataset_name)

            # Extract just the dataset name (last component)
            final_name = name or source.stem

            return DatasetState(
                name=final_name,
                pool=pool.name,
                path=path,
                quota_mb=None,
                used_bytes=self._parse_size(props.get("used", "0")),
                referenced_bytes=self._parse_size(props.get("referenced", "0")),
                compression=props.get("compression", pool.compression),
                created_at=datetime.now(),
                snapshots=[],
            )

        except subprocess.CalledProcessError as e:
            raise SendReceiveError("receive", e.stderr.decode()) from e
