"""Docker filesystem reader using tar-based API."""

from __future__ import annotations

import fnmatch
import io
import logging
import stat
import tarfile
from datetime import datetime
from typing import TYPE_CHECKING

import docker
from docker.errors import APIError, ImageNotFound, NotFound

from gaol.diff.exceptions import (
    DiffFilesystemAccessError,
    DiffImageNotFoundError,
    DiffSnapshotNotFoundError,
    DiffSourceNotFoundError,
)
from gaol.diff.models import (
    DiffOptions,
    FileEntry,
    FilesystemManifest,
    FileType,
)
from gaol.diff.readers.base import FilesystemReader

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)

# Snapshot image naming convention
SNAPSHOT_REPO_PREFIX = "gaol-snapshot"


class DockerFilesystemReader(FilesystemReader):
    """Read Docker container filesystems via tar API.

    Docker containers are accessed using the get_archive API which streams
    the filesystem as a tar archive. This avoids extracting to disk.

    Snapshots are Docker images created via `docker commit`.
    """

    def __init__(self) -> None:
        """Initialize Docker reader."""
        self._client: docker.DockerClient | None = None

    @property
    def client(self) -> docker.DockerClient:
        """Get or create Docker client."""
        if self._client is None:
            self._client = docker.from_env()
        return self._client

    @property
    def runtime_name(self) -> str:
        return "docker"

    def read_container(
        self,
        container_id: str,
        options: DiffOptions | None = None,
    ) -> FilesystemManifest:
        """Read filesystem manifest from a running or stopped container.

        Uses Docker's get_archive API to stream the filesystem as tar.

        Args:
            container_id: Container name or ID
            options: Diff options for filtering

        Returns:
            FilesystemManifest with all file entries
        """
        try:
            container = self.client.containers.get(container_id)
            container_name = container.name or container_id
        except NotFound:
            raise DiffSourceNotFoundError("container", container_id)

        if options is None:
            options = DiffOptions()

        manifest = FilesystemManifest(
            container_name=container_name,
            runtime=self.runtime_name,
            source_type="container",
            source_id=container_id,
        )

        try:
            # Stream the entire filesystem as tar
            bits, _ = container.get_archive("/")

            # Process tar stream without extracting to disk
            tar_stream = io.BytesIO()
            for chunk in bits:
                tar_stream.write(chunk)
            tar_stream.seek(0)

            with tarfile.open(fileobj=tar_stream, mode="r") as tar:
                for member in tar:
                    entry = self._tar_member_to_entry(member, options)
                    if entry:
                        manifest.add_entry(entry)

        except APIError as e:
            raise DiffFilesystemAccessError(
                container_name, f"Docker API error: {e}", e
            )
        except Exception as e:
            raise DiffFilesystemAccessError(
                container_name, f"Error reading filesystem: {e}", e
            )

        return manifest

    def read_snapshot(
        self,
        container_name: str,
        snapshot_name: str,
        options: DiffOptions | None = None,
    ) -> FilesystemManifest:
        """Read filesystem manifest from a snapshot image.

        Docker snapshots are stored as images named:
        gaol-snapshot/{container_name}:{snapshot_name}

        Creates a temporary container to read the filesystem.

        Args:
            container_name: Container name
            snapshot_name: Snapshot name (image tag)
            options: Diff options for filtering

        Returns:
            FilesystemManifest with all file entries
        """
        image_name = f"{SNAPSHOT_REPO_PREFIX}/{container_name}:{snapshot_name}"

        try:
            self.client.images.get(image_name)
        except ImageNotFound:
            raise DiffSnapshotNotFoundError(container_name, snapshot_name)

        if options is None:
            options = DiffOptions()

        manifest = FilesystemManifest(
            container_name=container_name,
            runtime=self.runtime_name,
            source_type="snapshot",
            source_id=snapshot_name,
        )

        # Create temporary container from snapshot image
        temp_container = None
        try:
            temp_container = self.client.containers.create(
                image=image_name,
                name=f"gaol-diff-temp-{snapshot_name[:8]}",
                command="true",  # Won't actually run
            )

            # Read filesystem from temp container
            bits, _ = temp_container.get_archive("/")

            tar_stream = io.BytesIO()
            for chunk in bits:
                tar_stream.write(chunk)
            tar_stream.seek(0)

            with tarfile.open(fileobj=tar_stream, mode="r") as tar:
                for member in tar:
                    entry = self._tar_member_to_entry(member, options)
                    if entry:
                        manifest.add_entry(entry)

        except APIError as e:
            raise DiffFilesystemAccessError(
                container_name, f"Docker API error: {e}", e
            )
        except Exception as e:
            raise DiffFilesystemAccessError(
                container_name, f"Error reading snapshot: {e}", e
            )
        finally:
            # Clean up temp container
            if temp_container:
                try:
                    temp_container.remove(force=True)
                except Exception:
                    pass

        return manifest

    def read_image(
        self,
        image_name: str,
        options: DiffOptions | None = None,
    ) -> FilesystemManifest:
        """Read filesystem manifest from a Docker image.

        Creates a temporary container to read the image filesystem.

        Args:
            image_name: Docker image name/tag
            options: Diff options for filtering

        Returns:
            FilesystemManifest with all file entries
        """
        try:
            self.client.images.get(image_name)
        except ImageNotFound:
            raise DiffImageNotFoundError("", image_name)

        if options is None:
            options = DiffOptions()

        # Generate safe container name from image
        safe_name = image_name.replace("/", "-").replace(":", "-")[:20]
        manifest = FilesystemManifest(
            container_name=safe_name,
            runtime=self.runtime_name,
            source_type="image",
            source_id=image_name,
        )

        temp_container = None
        try:
            temp_container = self.client.containers.create(
                image=image_name,
                name=f"gaol-diff-image-{safe_name}",
                command="true",
            )

            bits, _ = temp_container.get_archive("/")

            tar_stream = io.BytesIO()
            for chunk in bits:
                tar_stream.write(chunk)
            tar_stream.seek(0)

            with tarfile.open(fileobj=tar_stream, mode="r") as tar:
                for member in tar:
                    entry = self._tar_member_to_entry(member, options)
                    if entry:
                        manifest.add_entry(entry)

        except APIError as e:
            raise DiffFilesystemAccessError(
                image_name, f"Docker API error: {e}", e
            )
        except Exception as e:
            raise DiffFilesystemAccessError(
                image_name, f"Error reading image: {e}", e
            )
        finally:
            if temp_container:
                try:
                    temp_container.remove(force=True)
                except Exception:
                    pass

        return manifest

    def read_file_content(
        self,
        container_id: str,
        path: str,
        max_size: int = 1024 * 1024,
    ) -> bytes | None:
        """Read file content from a container.

        Args:
            container_id: Container name or ID
            path: File path within container
            max_size: Maximum bytes to read

        Returns:
            File content bytes or None if not found/too large
        """
        try:
            container = self.client.containers.get(container_id)

            # Get the specific file as tar
            bits, file_stat = container.get_archive(path)

            # Check size before reading
            file_size = file_stat.get("size", 0)
            if file_size > max_size:
                return None

            tar_stream = io.BytesIO()
            for chunk in bits:
                tar_stream.write(chunk)
            tar_stream.seek(0)

            with tarfile.open(fileobj=tar_stream, mode="r") as tar:
                # Get the first (only) member
                members = tar.getmembers()
                if not members:
                    return None

                member = members[0]
                if not member.isfile():
                    return None

                f = tar.extractfile(member)
                if f:
                    return f.read()

            return None

        except NotFound:
            return None
        except Exception as e:
            logger.debug(f"Failed to read {path}: {e}")
            return None

    def _tar_member_to_entry(
        self,
        member: tarfile.TarInfo,
        options: DiffOptions,
    ) -> FileEntry | None:
        """Convert a tar member to a FileEntry.

        Args:
            member: Tar archive member
            options: Diff options for filtering

        Returns:
            FileEntry or None if filtered out
        """
        # Build path with leading slash
        path = "/" + member.name.lstrip("./")
        if path == "/":
            # Skip root entry
            return None

        # Check exclusions
        if not self._should_include_path(path, options):
            return None

        # Determine file type
        if member.isfile():
            file_type = FileType.FILE
        elif member.isdir():
            file_type = FileType.DIRECTORY
        elif member.issym():
            file_type = FileType.SYMLINK
        elif member.ischr() or member.isblk():
            file_type = FileType.DEVICE
        elif member.isfifo():
            file_type = FileType.FIFO
        else:
            file_type = FileType.UNKNOWN

        # Parse mtime
        mtime = None
        if member.mtime:
            try:
                mtime = datetime.fromtimestamp(member.mtime)
            except (ValueError, OSError):
                pass

        return FileEntry(
            path=path,
            file_type=file_type,
            size=member.size,
            mode=member.mode & 0o7777,
            uid=member.uid,
            gid=member.gid,
            mtime=mtime,
            link_target=member.linkname if member.issym() else None,
        )

    def _should_include_path(self, path: str, options: DiffOptions) -> bool:
        """Check if path should be included based on options."""
        # Check exclusions
        for pattern in options.exclude_paths:
            if fnmatch.fnmatch(path, pattern):
                return False

        # Check inclusions
        if options.include_paths:
            for pattern in options.include_paths:
                if fnmatch.fnmatch(path, pattern):
                    return True
            return False

        return True
