"""Shared base class for resource handlers.

Provides the common ``resolve`` logic used by both
:class:`GitCheckoutHandler` and :class:`FsDirectoryHandler`.
Subclasses set ``_default_strategy`` and ``_type_label`` to
customise behaviour.

Also provides default (``NotImplementedError``) implementations for
the CRUD and discovery methods added by issue #827.  Concrete
handlers override the methods they support.
"""

from __future__ import annotations

import hashlib
import logging
import os
from pathlib import Path
from typing import ClassVar, cast

from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
from cleveragents.infrastructure.sandbox.factory import SandboxStrategyStr
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.resource.handlers.protocol import (
    AccessResult,
    CheckpointResult,
    Content,
    DeleteResult,
    DiffResult,
    RollbackResult,
    SandboxResult,
    WriteResult,
)
from cleveragents.tool.context import BoundResource

logger = logging.getLogger(__name__)

# Crockford Base32 alphabet (excludes I, L, O, U)
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"


def _derive_child_id(parent_id: str, child_name: str) -> str:
    """Derive a valid 26-char Crockford Base32 child resource ID.

    Uses a SHA-256 hash of ``parent_id + "/" + child_name``, encoded
    in Crockford Base32 to satisfy the ULID pattern constraint on
    :class:`Resource.resource_id`.
    """
    digest = hashlib.sha256(f"{parent_id}/{child_name}".encode()).digest()
    # Take first 16 bytes (128 bits) and encode as 26 Crockford chars
    n = int.from_bytes(digest[:16], "big")
    chars: list[str] = []
    for _ in range(26):
        chars.append(_CB32[n % 32])
        n //= 32
    return "".join(reversed(chars))


#: Sentinel hash returned for empty or missing resources.
EMPTY_CONTENT_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"


class BaseResourceHandler:
    """Base handler with shared resolve logic and CRUD defaults.

    Subclasses **must** set:

    * ``_default_strategy`` — the :class:`SandboxStrategy` to use when
      the resource has no explicit override.
    * ``_type_label`` — a human-readable label for error messages
      (e.g. ``"git-checkout"``).

    Subclasses **may** override the CRUD and discovery methods
    (``read``, ``write``, ``delete``, ``list_children``, ``diff``,
    ``discover_children``) to provide handler-specific behaviour.
    The defaults raise :class:`NotImplementedError`.
    """

    _default_strategy: ClassVar[SandboxStrategy]
    _type_label: ClassVar[str]

    # -- Sandbox resolution (M1) -------------------------------------------

    def resolve(
        self,
        *,
        resource: Resource,
        plan_id: str,
        slot_name: str,
        sandbox_manager: SandboxManager,
        access: str = "read_only",
    ) -> BoundResource:
        """Resolve a resource into a sandboxed BoundResource.

        Args:
            resource: A resource with ``location`` pointing to the
                original path.
            plan_id: The plan requesting the sandbox.
            slot_name: Name of the tool resource slot being filled.
            sandbox_manager: The sandbox lifecycle manager.
            access: Access mode (``read_only`` or ``read_write``).

        Returns:
            A :class:`BoundResource` with ``sandbox_path`` populated.

        Raises:
            ValueError: If the resource has no location.
            RuntimeError: If the sandbox was created but has no context.
        """
        if not resource.location:
            raise ValueError(
                f"{self._type_label} resource '{resource.resource_id}' has no location"
            )

        strategy_raw = resource.sandbox_strategy or self._default_strategy
        strategy_str = (
            strategy_raw.value if hasattr(strategy_raw, "value") else str(strategy_raw)
        )

        logger.debug(
            "Resolving %s resource %s (location=%s, strategy=%s)",
            self._type_label,
            resource.resource_id,
            resource.location,
            strategy_str,
        )

        sandbox = sandbox_manager.get_or_create_sandbox(
            plan_id=plan_id,
            resource_id=resource.resource_id,
            original_path=resource.location,
            sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
        )

        if sandbox.context is None:
            raise RuntimeError(
                f"Sandbox for resource '{resource.resource_id}' "
                f"(plan={plan_id}) was created but has no context"
            )

        return BoundResource(
            slot_name=slot_name,
            resource_id=resource.resource_id,
            resource_type=resource.resource_type_name,
            sandbox_path=sandbox.context.sandbox_path,
            access=access,
        )

    # -- Content CRUD (M6, issue #827) -------------------------------------

    @staticmethod
    def _derive_child_id(parent_id: str, child_name: str) -> str:
        """Derive a valid 26-char Crockford Base32 child ID.

        Uses a SHA-256 hash of parent + child name, encoded in
        Crockford Base32 to satisfy the ULID pattern constraint.
        """
        return _derive_child_id(parent_id, child_name)

    def _require_location(self, resource: Resource) -> str:
        """Return *resource.location* or raise ``ValueError``."""
        if not resource.location:
            raise ValueError(
                f"{self._type_label} resource '{resource.resource_id}' has no location"
            )
        return resource.location

    @staticmethod
    def _safe_resolve(location: str, path: str) -> Path:
        """Resolve *path* within *location*, rejecting traversal escapes.

        Args:
            location: The root directory.
            path: Relative path within the root.

        Returns:
            The resolved :class:`Path`.

        Raises:
            PermissionError: If *path* escapes the root via ``..`` or
                symlink resolution.
        """
        root = Path(location).resolve()
        target = (root / path).resolve()
        # Use root + os.sep to prevent prefix collision bypass:
        # e.g. root=/tmp/foo must not match target=/tmp/foobar/secret
        if target != root and not str(target).startswith(str(root) + os.sep):
            raise PermissionError(f"Path '{path}' escapes resource root '{location}'")
        return target

    def read(self, *, resource: Resource, path: str = "") -> Content:
        """Read content from a resource.

        Args:
            resource: The resource to read from.
            path: Relative path within the resource.

        Raises:
            NotImplementedError: Subclass has not implemented ``read``.
        """
        raise NotImplementedError(f"{self._type_label} handler does not support read()")

    def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult:
        """Write content to a resource.

        Args:
            resource: The resource to write to.
            path: Relative path within the resource.
            data: Raw bytes to write.

        Raises:
            NotImplementedError: Subclass has not implemented ``write``.
        """
        raise NotImplementedError(
            f"{self._type_label} handler does not support write()"
        )

    def delete(self, *, resource: Resource, path: str = "") -> DeleteResult:
        """Delete a resource or a path within it.

        Args:
            resource: The resource to delete from.
            path: Relative path within the resource.

        Raises:
            NotImplementedError: Subclass has not implemented ``delete``.
        """
        raise NotImplementedError(
            f"{self._type_label} handler does not support delete()"
        )

    def list_children(self, *, resource: Resource) -> list[str]:
        """List direct children of a resource.

        Args:
            resource: The parent resource.

        Raises:
            NotImplementedError: Subclass has not implemented
                ``list_children``.
        """
        raise NotImplementedError(
            f"{self._type_label} handler does not support list_children()"
        )

    def diff(self, *, resource: Resource, other_location: str) -> DiffResult:
        """Compare a resource against another location.

        Args:
            resource: The resource whose current state to compare.
            other_location: Filesystem path to compare against.

        Raises:
            NotImplementedError: Subclass has not implemented ``diff``.
        """
        raise NotImplementedError(f"{self._type_label} handler does not support diff()")

    def discover_children(self, *, resource: Resource) -> list[Resource]:
        """Auto-discover child resources beneath this resource.

        Args:
            resource: The parent resource to scan.

        Raises:
            NotImplementedError: Subclass has not implemented
                ``discover_children``.
        """
        raise NotImplementedError(
            f"{self._type_label} handler does not support discover_children()"
        )

    def _resolve_strategy_str(self, resource: Resource) -> str:
        """Return the sandbox strategy string for the resource."""
        raw = resource.sandbox_strategy or self._default_strategy
        return raw.value if hasattr(raw, "value") else str(raw)

    # -- Sandbox lifecycle (issue #836) ------------------------------------

    def create_sandbox(
        self,
        *,
        resource: Resource,
        plan_id: str,
        sandbox_manager: SandboxManager,
    ) -> SandboxResult:
        """Create an isolated sandbox (idempotent, delegates to SandboxManager)."""
        location = self._require_location(resource)
        strategy_str = self._resolve_strategy_str(resource)

        existing = sandbox_manager.get_sandbox(
            plan_id=plan_id, resource_id=resource.resource_id
        )
        if existing is not None and existing.context is not None:
            return SandboxResult(
                sandbox_id=existing.sandbox_id,
                sandbox_path=existing.context.sandbox_path,
                strategy=strategy_str,
                created=False,
                message="Reused existing sandbox",
            )

        sandbox = sandbox_manager.get_or_create_sandbox(
            plan_id=plan_id,
            resource_id=resource.resource_id,
            original_path=location,
            sandbox_strategy=cast(SandboxStrategyStr, strategy_str),
        )
        if sandbox.context is None:
            raise RuntimeError(
                f"Sandbox for resource '{resource.resource_id}' "
                f"(plan={plan_id}) was created but has no context"
            )
        return SandboxResult(
            sandbox_id=sandbox.sandbox_id,
            sandbox_path=sandbox.context.sandbox_path,
            strategy=strategy_str,
            created=True,
            message=f"Created {strategy_str} sandbox",
        )

    def create_checkpoint(
        self,
        *,
        resource: Resource,
        plan_id: str,
        sandbox_manager: SandboxManager,
        phase: str = "execution",
    ) -> CheckpointResult:
        """Create a checkpoint. Override in subclasses."""
        raise NotImplementedError(
            f"{self._type_label} handler does not support create_checkpoint()"
        )

    def rollback_to(
        self,
        *,
        resource: Resource,
        plan_id: str,
        checkpoint_id: str,
        sandbox_manager: SandboxManager,
    ) -> RollbackResult:
        """Rollback to a checkpoint. Override in subclasses."""
        raise NotImplementedError(
            f"{self._type_label} handler does not support rollback_to()"
        )

    def project_access(
        self,
        *,
        resource: Resource,
        principal: str,
        action: str = "read",
        project_id: str = "",
    ) -> AccessResult:
        """Check access permissions (default: local mode = always permit)."""
        try:
            from cleveragents.application.services.permission_service import (
                get_default_permission_service,
            )
            from cleveragents.domain.models.core.permission import (
                PermissionAction,
                PermissionScope,
            )

            svc = get_default_permission_service()
            action_enum = PermissionAction(action.lower())
            check = svc.check_permission(
                principal=principal,
                action=action_enum,
                scope=PermissionScope.PROJECT,
                scope_id=project_id or resource.resource_id,
            )
            return AccessResult(
                permitted=check.result,
                principal=principal,
                action=action,
                scope="project",
                reason=check.reason,
            )
        except ImportError:
            return AccessResult(
                permitted=True,
                principal=principal,
                action=action,
                scope="project",
                reason="Local mode — all access permitted",
            )
        except ValueError as exc:
            # Invalid action string — reject rather than silently permit
            return AccessResult(
                permitted=False,
                principal=principal,
                action=action,
                scope="project",
                reason=f"Invalid action: {exc}",
            )

    # -- Content Hashing (#836) ---------------------------------------------

    def content_hash(
        self,
        resource: Resource,
        *,
        algorithm: str = "sha256",
    ) -> str:
        """Compute a deterministic content hash.

        Default implementation: if the resource has a ``location``
        pointing to a file, hash that file.  If the location points
        to a directory, hash the sorted list of filenames (shallow).
        Returns :data:`EMPTY_CONTENT_HASH` for missing or empty
        resources.

        Subclasses should override for type-specific hashing.
        """
        if not resource.location or not os.path.exists(resource.location):
            return EMPTY_CONTENT_HASH

        path = resource.location
        h = hashlib.new(algorithm)

        if os.path.isfile(path):
            with open(path, "rb") as f:
                for chunk in iter(lambda: f.read(65536), b""):
                    h.update(chunk)
        elif os.path.isdir(path):
            # Shallow: hash sorted entry names for change detection
            entries = sorted(os.listdir(path))
            for entry in entries:
                h.update(entry.encode("utf-8"))
                h.update(b"\0")
        else:
            return EMPTY_CONTENT_HASH

        return h.hexdigest()
