# Copyright 2024 Klira AI
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Policy loaders — remote (API) and local (filesystem).

Unified module with both loaders (no separate files per the plan).
Policy loading is synchronous at init time (Learning #9, PROD-145).
"""

from __future__ import annotations

import json
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any

from klira.sdk.exceptions import KliraConfigError
from klira.sdk.types import Policy
from klira.sdk.utils.validation import validate_endpoint, validate_path

logger = logging.getLogger(__name__)

MAX_POLICY_FILE_BYTES = 1 * 1024 * 1024  # 1 MB
MAX_REMOTE_RESPONSE_BYTES = 1 * 1024 * 1024  # 1 MB


class PolicyLoader(ABC):
    """Abstract base for policy loaders."""

    @abstractmethod
    def load(self) -> list[Policy]:
        """Load policies. Returns list of Policy objects."""


class RemotePolicyLoader(PolicyLoader):
    """Loads policies from the Klira API.

    Fetched once at init(), cached for instance lifetime.
    Falls back to local files if remote fetch fails.
    """

    def __init__(
        self,
        api_key: str,
        endpoint: str,
        fallback_path: str | None = None,
    ) -> None:
        self._api_key = api_key
        self._endpoint = endpoint
        self._fallback_path = fallback_path
        validate_endpoint(endpoint)
        self._cached: list[Policy] | None = None

    def load(self) -> list[Policy]:
        """Load policies from the remote API.

        On failure, falls back to local policies if fallback_path is set.
        """
        if self._cached is not None:
            return self._cached

        try:
            policies = self._fetch_remote()
            self._cached = policies
            logger.debug("Loaded %d policies from remote API", len(policies))
            return policies
        except Exception:
            logger.warning(
                "Failed to fetch remote policies, using fallback", exc_info=True
            )
            if self._fallback_path:
                loader = FileSystemPolicyLoader(self._fallback_path)
                policies = loader.load()
                self._cached = policies
                return policies
            self._cached = []
            return []

    def _fetch_remote(self) -> list[Policy]:
        """Fetch policies from the API."""
        import urllib.request

        url = self._endpoint
        req = urllib.request.Request(
            url,
            headers={
                "Authorization": f"Bearer {self._api_key}",
                "Content-Type": "application/json",
            },
        )

        with urllib.request.urlopen(req, timeout=10) as response:
            body = response.read(MAX_REMOTE_RESPONSE_BYTES + 1)
            if len(body) > MAX_REMOTE_RESPONSE_BYTES:
                raise KliraConfigError(
                    f"Remote policy response too large "
                    f"(limit: {MAX_REMOTE_RESPONSE_BYTES})"
                )
            data = json.loads(body.decode())

        policies: list[Policy] = []
        raw_policies: list[dict[str, Any]] = (
            data if isinstance(data, list) else data.get("policies", [])
        )
        for raw in raw_policies:
            try:
                policies.append(Policy.from_dict(raw))
            except Exception:
                logger.warning(
                    "Failed to parse policy: %s", raw.get("id", "unknown"),
                    exc_info=True,
                )

        return policies


class FileSystemPolicyLoader(PolicyLoader):
    """Loads policies from local YAML or JSON files.

    Supports both single files and directories of policy files.
    """

    def __init__(self, path: str) -> None:
        self._path = validate_path(path)

    def load(self) -> list[Policy]:
        """Load policies from the filesystem."""
        if not self._path.exists():
            logger.warning("Policy path does not exist: %s", self._path)
            return []

        if self._path.is_file():
            return self._load_file(self._path)

        if self._path.is_dir():
            policies: list[Policy] = []
            for file_path in sorted(self._path.iterdir()):
                if file_path.suffix in (".yaml", ".yml", ".json"):
                    policies.extend(self._load_file(file_path))
            return policies

        return []

    def _load_file(self, path: Path) -> list[Policy]:
        """Load policies from a single file."""
        file_size = path.stat().st_size
        if file_size > MAX_POLICY_FILE_BYTES:
            logger.warning(
                "Policy file too large (%d bytes, limit %d): %s",
                file_size, MAX_POLICY_FILE_BYTES, path,
            )
            raise KliraConfigError(
                f"Policy file too large: {file_size} bytes "
                f"(limit: {MAX_POLICY_FILE_BYTES})"
            )
        try:
            content = path.read_text()

            if path.suffix in (".yaml", ".yml"):
                return self._parse_yaml(content)
            elif path.suffix == ".json":
                return self._parse_json(content)
            else:
                logger.warning("Unsupported policy file format: %s", path)
                return []
        except KliraConfigError:
            raise
        except Exception:
            logger.warning("Failed to load policy file: %s", path, exc_info=True)
            return []

    def _parse_yaml(self, content: str) -> list[Policy]:
        """Parse YAML policy content."""
        try:
            import yaml
            from yaml.composer import ComposerError
        except ImportError:
            logger.warning("PyYAML not installed — cannot load YAML policies")
            return []

        class _SafePolicyLoader(yaml.SafeLoader):
            """SafeLoader that rejects YAML aliases (PROD-487)."""

            def compose_node(self, parent: Any, index: Any) -> Any:
                if self.check_event(yaml.events.AliasEvent):
                    event = self.get_event()
                    raise ComposerError(
                        None, None,
                        f"YAML aliases are not permitted in policy files "
                        f"(anchor: {event.anchor!r})",
                        event.start_mark,
                    )
                return super().compose_node(parent, index)

        try:
            data = yaml.load(content, Loader=_SafePolicyLoader)  # noqa: S506
        except ComposerError as exc:
            raise KliraConfigError(
                f"YAML alias expansion is not permitted in policy files: {exc}"
            ) from exc
        if isinstance(data, dict) and "policies" in data:
            data = data["policies"]
        if not isinstance(data, list):
            data = [data]
        policies: list[Policy] = []
        for item in data:
            if isinstance(item, dict):
                try:
                    policies.append(Policy.from_dict(item))
                except Exception:
                    logger.warning(
                        "Failed to parse policy: %s",
                        item.get("id", "unknown"),
                        exc_info=True,
                    )
        return policies

    def _parse_json(self, content: str) -> list[Policy]:
        """Parse JSON policy content."""
        data = json.loads(content)
        if not isinstance(data, list):
            data = [data]
        policies: list[Policy] = []
        for item in data:
            if isinstance(item, dict):
                try:
                    policies.append(Policy.from_dict(item))
                except Exception:
                    logger.warning(
                        "Failed to parse policy: %s",
                        item.get("id", "unknown"),
                        exc_info=True,
                    )
        return policies
