"""Executor for running operations with caching.

The executor wraps operation execution with cache logic:
- Computes cache keys from operation name + params + input hashes
- Checks cache before executing (cache hit = instant restore)
- Stores results after execution (cache miss = execute + store)
"""

from __future__ import annotations

import asyncio
import hashlib
import importlib
import inspect
import json
import tempfile
from collections.abc import Coroutine
from pathlib import Path
from typing import Any, cast

from cascade_fm.core.types import OperationResult
from cascade_fm.operations.base import Operation, OperationStatus


class OperationExecutor:
    """Executor for running operations with caching support."""

    def __init__(self, cache: Any | None = None):
        """Initialize the executor.

        Args:
            cache: Cache backend to use. If None, creates a session-scoped
                  LocalCache in a temp directory.
        """
        if cache is None:
            # Create session-scoped temp cache
            cache_dir = Path(tempfile.mkdtemp(prefix="cascade_cache_"))
            local_cache_cls = getattr(importlib.import_module("cascache_lib"), "LocalCache")
            self.cache = local_cache_cls(cache_dir=cache_dir)
            self._owns_cache = True
        else:
            self.cache = cache
            self._owns_cache = False

        # Session-local cache manifest: cache_key -> output filenames.
        # This allows us to create deterministic restore paths for cache hits.
        self._cache_manifest: dict[str, list[str]] = {}
        # Session-local lineage map: path -> lineage/signature metadata.
        self._lineage_map: dict[str, dict[str, Any]] = {}

    def execute(
        self,
        operation: Operation,
        files: list[Path],
        params: dict[str, Any] | None = None,
    ) -> OperationResult:
        """Execute an operation with artifact caching when appropriate.

        Only operations that produce materialized artifacts should be cached.
        Pure selection/filter operations execute directly without cache
        storage/restore to avoid unnecessary file copying.

        Args:
            operation: Operation to execute.
            files: Input file paths.
            params: Operation parameters (default: empty dict).

        Returns:
            OperationResult with success status and output files.
        """
        params = params or {}

        # Validate parameters
        valid, error = operation.validate_params(**params)
        if not valid:
            return OperationResult.fail(f"Invalid parameters: {error}")

        if operation.caches_artifacts and operation.supports_incremental_cache:
            return self._execute_incremental(operation, files, params)

        cache_key = self._compute_cache_key(operation.name, files, params)
        cache_hit = operation.cache_load(
            cache_key,
            files,
            params,
            load_artifacts=self._try_restore_from_cache,
        )
        if cache_hit is not None:
            return cache_hit

        # Execute operation via reactive API
        operation.set_params(params)
        operation.set_input_files(files)

        if operation.status == OperationStatus.ERROR:
            return OperationResult.fail(operation.error or "Operation failed")

        result = OperationResult.ok(operation.output_files)

        operation.cache_store(
            cache_key,
            result,
            files,
            params,
            store_artifacts=self._store_in_cache,
        )

        return result

    def _execute_incremental(
        self,
        operation: Operation,
        files: list[Path],
        params: dict[str, Any],
    ) -> OperationResult:
        """Execute an operation incrementally with per-file cache keys."""
        all_outputs: list[Path] = []

        for file_path in files:
            cache_key = self._compute_cache_key(operation.name, [file_path], params)
            cached = operation.cache_load(
                cache_key,
                [file_path],
                params,
                load_artifacts=self._try_restore_from_cache,
            )
            if cached is not None:
                all_outputs.extend(cached.files)
                continue

            execution = self._execute_single_file(operation, file_path, params)
            if not execution.success:
                return execution

            all_outputs.extend(execution.files)
            operation.cache_store(
                cache_key,
                execution,
                [file_path],
                params,
                store_artifacts=self._store_in_cache,
            )

        return OperationResult.ok(all_outputs)

    def _execute_single_file(
        self,
        operation: Operation,
        file_path: Path,
        params: dict[str, Any],
    ) -> OperationResult:
        """Execute operation for a single file.

        A fresh instance is preferred to avoid state carry-over.
        """
        operation_instance = self._create_fresh_operation(operation)

        operation_instance.set_params(params)
        operation_instance.set_input_files([file_path])

        if operation_instance.status == OperationStatus.ERROR:
            return OperationResult.fail(operation_instance.error or "Operation failed")

        return OperationResult.ok(operation_instance.output_files)

    def _create_fresh_operation(self, operation: Operation) -> Operation:
        """Create a fresh operation instance, falling back to original if needed."""
        operation_type = type(operation)
        try:
            return operation_type()
        except TypeError:
            return operation

    def _compute_cache_key(
        self,
        operation_name: str,
        files: list[Path],
        params: dict[str, Any],
    ) -> str:
        """Compute cache key from operation + params + file metadata."""
        normalized_files = []
        for file_path in sorted(files, key=lambda path: str(path)):
            lineage_id = self._get_lineage_id(file_path)
            if lineage_id is not None:
                normalized_files.append({"lineage": lineage_id})
                continue

            exists = file_path.exists()
            file_info: dict[str, Any] = {
                "path": str(file_path),
                "exists": exists,
                "is_file": file_path.is_file() if exists else False,
                "is_dir": file_path.is_dir() if exists else False,
            }

            if exists:
                stat = file_path.stat()
                file_info["size"] = stat.st_size
                file_info["mtime_ns"] = stat.st_mtime_ns

            normalized_files.append(file_info)

        payload = {
            "version": 1,
            "strategy": "artifact_metadata",
            "operation": operation_name,
            "params": params,
            "files": normalized_files,
        }
        encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
        return hashlib.sha256(encoded).hexdigest()

    def _try_restore_from_cache(self, cache_key: str) -> OperationResult | None:
        """Try restoring outputs from cache; return None on cache miss."""
        if not self._cache_call("exists", cache_key):
            return None

        output_names = self._cache_manifest.get(cache_key)
        if not output_names:
            return None

        restored_paths = self._get_cached_outputs(cache_key, output_names)
        restored = self._cache_call("get", cache_key, restored_paths)
        if not restored:
            return None

        for index, restored_path in enumerate(restored_paths):
            lineage_id = self._build_lineage_id(cache_key, index)
            self._set_lineage(restored_path, lineage_id)

        return OperationResult.ok(restored_paths)

    def _store_in_cache(self, cache_key: str, output_files: list[Path]) -> None:
        """Store operation outputs in cache and update manifest."""
        if not output_files:
            return

        stored = self._cache_call("put", cache_key, output_files)
        if stored:
            self._cache_manifest[cache_key] = [path.name for path in output_files]
            for index, output_path in enumerate(output_files):
                lineage_id = self._build_lineage_id(cache_key, index)
                self._set_lineage(output_path, lineage_id)

    def _get_cached_outputs(self, cache_key: str, output_names: list[str]) -> list[Path]:
        """Generate output paths for cached restoration.

        Args:
            cache_key: Cache key.
            output_names: Expected output file names.

        Returns:
            List of temporary output paths to restore to.
        """
        # Use deterministic temp directory so downstream per-file cache keys stay stable.
        temp_dir = Path(tempfile.gettempdir()) / f"cascade_restore_{cache_key}"
        temp_dir.mkdir(parents=True, exist_ok=True)
        outputs = []
        for name in output_names:
            outputs.append(temp_dir / name)
        return outputs

    def clear_cache(self) -> None:
        """Clear all cached artifacts."""
        self._cache_call("clear")
        self._cache_manifest.clear()
        self._lineage_map.clear()

    def _cache_call(self, method: str, *args: Any) -> Any:
        """Call cache backend methods supporting sync and async implementations."""
        result = getattr(self.cache, method)(*args)
        if inspect.isawaitable(result):
            try:
                asyncio.get_running_loop()
            except RuntimeError:
                return asyncio.run(cast(Coroutine[Any, Any, Any], result))
            return False
        return result

    def _build_lineage_id(self, cache_key: str, index: int) -> str:
        """Build deterministic lineage ID for an output entry."""
        return f"{cache_key}:{index}"

    def _set_lineage(self, path: Path, lineage_id: str) -> None:
        """Remember lineage and current file signature for a path."""
        signature = self._file_signature(path)
        self._lineage_map[str(path)] = {
            "lineage": lineage_id,
            "signature": signature,
        }

    def _get_lineage_id(self, path: Path) -> str | None:
        """Get lineage ID if path still matches the previously known signature."""
        key = str(path)
        record = self._lineage_map.get(key)
        if record is None:
            return None

        current_signature = self._file_signature(path)
        if current_signature != record["signature"]:
            self._lineage_map.pop(key, None)
            return None

        return str(record["lineage"])

    def _file_signature(self, path: Path) -> dict[str, Any]:
        """Build current metadata signature for a path."""
        exists = path.exists()
        signature: dict[str, Any] = {
            "exists": exists,
            "is_file": path.is_file() if exists else False,
            "is_dir": path.is_dir() if exists else False,
        }
        if exists:
            stat = path.stat()
            signature["size"] = stat.st_size
            signature["mtime_ns"] = stat.st_mtime_ns
        return signature

    def __del__(self):
        """Clean up cache on executor destruction if we own it."""
        if self._owns_cache:
            try:
                self.clear_cache()
            except Exception:
                pass  # Ignore errors during cleanup
