"""OpenSourceMalware API client: HTTPS GET with caching and rate limiting."""

from __future__ import annotations

import json
import logging
import os
import time
from urllib.parse import urlencode, urlparse, urlunparse
from typing import Any, Dict, Optional, Tuple

try:
    from constants import Constants
    from common.logging_utils import extra_context, is_debug_enabled, safe_url, Timer
    from common.http_metrics import increment
    from common.http_client import robust_get
except ImportError:
    from src.constants import Constants
    from src.common.logging_utils import extra_context, is_debug_enabled, safe_url, Timer
    from src.common.http_metrics import increment
    from src.common.http_client import robust_get

try:
    from src.versioning.cache import TTLCache  # type: ignore
except Exception:  # pylint: disable=broad-exception-caught
    from versioning.cache import TTLCache  # type: ignore

logger = logging.getLogger(__name__)
SERVICE = "api.opensourcemalware.com"
HEADERS_JSON = {"Accept": "application/json"}


class OpenSourceMalwareClient:
    """Lightweight OpenSourceMalware client using robust_get and in-run/file cache."""

    def __init__(
        self,
        base_url: Optional[str] = None,
        api_token: Optional[str] = None,
        cache_ttl_sec: Optional[int] = None,
        auth_method: Optional[str] = None,
        file_cache_path: Optional[str] = None,
    ) -> None:
        """Initialize OpenSourceMalware client.

        Args:
            base_url: Base URL for API (defaults to Constants.OSM_API_BASE_URL)
            api_token: API token for authentication
            cache_ttl_sec: Cache TTL in seconds (defaults to Constants.OSM_CACHE_TTL_SEC)
            auth_method: Authentication method - "header" or "query" (defaults to Constants.OSM_AUTH_METHOD)
            file_cache_path: Optional file cache path
        """
        b = base_url or Constants.OSM_API_BASE_URL  # type: ignore[attr-defined]
        if isinstance(b, str):
            # Parse URL and enforce HTTPS using urllib.parse for robustness
            parsed = urlparse(b)

            # Determine netloc and path from parsed result
            if parsed.scheme in ("https", "http"):
                # Valid HTTP scheme - use parsed components directly
                netloc = parsed.netloc
                path = parsed.path
                scheme = "https"  # Always use HTTPS
            else:
                # No scheme or invalid scheme - extract from path/netloc
                # urlparse handles edge cases like "://api.example.com" correctly
                # (scheme='', netloc='', path='://api.example.com')
                if parsed.netloc:
                    netloc = parsed.netloc
                    path = parsed.path
                else:
                    # No netloc, so path contains everything (including malformed prefixes)
                    # Strip common malformed prefixes from path
                    path = parsed.path
                    for prefix in ["://", ":/", "//"]:
                        if path.startswith(prefix):
                            path = path[len(prefix):]
                            break
                    # Extract netloc from path (first segment before /)
                    if "/" in path:
                        netloc, path = path.split("/", 1)
                        path = "/" + path
                    else:
                        netloc = path
                        path = ""
                scheme = "https"

            # Remove trailing slash from path
            if path.endswith("/"):
                path = path.rstrip("/")

            # Reconstruct URL
            b = urlunparse((scheme, netloc, path, parsed.params, parsed.query, parsed.fragment))
        self.base_url = b
        self.api_token = api_token or Constants.OSM_API_TOKEN  # type: ignore[attr-defined]
        self.cache_ttl_sec = int(cache_ttl_sec or Constants.OSM_CACHE_TTL_SEC)  # type: ignore[attr-defined]
        # Validate and set auth_method
        auth_method_value = (auth_method or Constants.OSM_AUTH_METHOD).lower()  # type: ignore[attr-defined]
        if auth_method_value in ("header", "query"):
            self.auth_method = auth_method_value
        else:
            # Default to "header" if invalid value provided
            logger.warning(
                "Invalid OpenSourceMalware auth_method '%s', defaulting to 'header'. "
                "Valid values are 'header' or 'query'.",
                auth_method_value
            )
            self.auth_method = "header"
        self._cache = TTLCache()
        self._file_cache: Dict[str, Dict[str, Any]] = {}
        self._file_cache_path = file_cache_path or os.path.join(".uv-cache", "osm_cache.json")
        try:
            os.makedirs(os.path.dirname(self._file_cache_path), exist_ok=True)
        except Exception:  # pylint: disable=broad-exception-caught
            pass
        self._load_file_cache()

    @staticmethod
    def _eco_value(ecosystem: str) -> str:
        """Normalize ecosystem name to API format.

        Args:
            ecosystem: Ecosystem name (npm, pypi, maven, nuget, etc.)

        Returns:
            Normalized ecosystem name
        """
        e = (ecosystem or "").lower()
        if e in ("npm",):
            return "npm"
        if e in ("pypi", "py", "python"):
            return "pypi"
        if e in ("maven", "java"):
            return "maven"
        if e in ("nuget", "dotnet", ".net"):
            return "nuget"
        if e in ("rubygems", "gem", "ruby"):
            return "rubygems"
        return e or "npm"

    def _load_file_cache(self) -> None:
        """Load file cache from disk."""
        try:
            if os.path.isfile(self._file_cache_path):
                with open(self._file_cache_path, "r", encoding="utf-8") as fh:
                    data = json.load(fh) or {}
                    if isinstance(data, dict):
                        self._file_cache = data
        except Exception:  # pylint: disable=broad-exception-caught
            self._file_cache = {}

    def _save_file_cache(self) -> None:
        """Save file cache to disk."""
        try:
            with open(self._file_cache_path, "w", encoding="utf-8") as fh:
                json.dump(self._file_cache, fh)
        except Exception:  # pylint: disable=broad-exception-caught
            pass

    def _file_get(self, key: str) -> Optional[Dict[str, Any]]:
        """Get value from file cache if still valid.

        Args:
            key: Cache key

        Returns:
            Cached value or None
        """
        try:
            entry = self._file_cache.get(key)
            if not entry or not isinstance(entry, dict):
                return None
            exp = entry.get("expires_at")
            if isinstance(exp, (int, float)) and time.time() < float(exp):
                return entry.get("value")
            return None
        except Exception:  # pylint: disable=broad-exception-caught
            return None

    def _file_put(self, key: str, value: Dict[str, Any], ttl: int) -> None:
        """Store value in file cache.

        Args:
            key: Cache key
            value: Value to cache
            ttl: Time to live in seconds
        """
        try:
            self._file_cache[key] = {"value": value, "expires_at": time.time() + ttl}
            self._save_file_cache()
        except Exception:  # pylint: disable=broad-exception-caught
            pass

    def _cache_key(self, url: str) -> str:
        """Generate cache key from URL.

        Args:
            url: Request URL

        Returns:
            Cache key string
        """
        return url

    def _cache_hit(self, where: str, url: str) -> None:
        """Log cache hit.

        Args:
            where: Cache location ("memory" or "file")
            url: Request URL
        """
        if is_debug_enabled(logger):
            logger.debug(
                "OpenSourceMalware cache hit",
                extra=extra_context(
                    event="osm_cache_hit",
                    component="osm_client",
                    target=safe_url(url),
                    cache=where,
                ),
            )
        increment(SERVICE, "attempts_total")

    def _cache_miss(self, where: str, url: str) -> None:
        """Log cache miss.

        Args:
            where: Cache location ("memory" or "file")
            url: Request URL
        """
        if is_debug_enabled(logger):
            logger.debug(
                "OpenSourceMalware cache miss",
                extra=extra_context(
                    event="osm_cache_miss",
                    component="osm_client",
                    target=safe_url(url),
                    cache=where,
                ),
            )

    def _build_url(self, ecosystem: str, package_name: str, version: Optional[str] = None) -> str:
        """Build API URL with query parameters.

        Args:
            ecosystem: Package ecosystem
            package_name: Package name
            version: Optional package version

        Returns:
            Complete API URL
        """
        eco = self._eco_value(ecosystem)
        params = {
            "package_name": package_name,
            "ecosystem": eco,
        }
        if version:
            params["version"] = version

        # Add authentication
        if self.api_token:
            if self.auth_method == "query":
                params["apikey"] = self.api_token
            # If header method, token will be added to headers in _request_json

        url = f"{self.base_url}/check-package-malicious"
        if params:
            url += "?" + urlencode(params)

        return url

    def _build_headers(self) -> Dict[str, str]:
        """Build request headers with authentication if needed.

        Returns:
            Headers dictionary
        """
        headers = dict(HEADERS_JSON)
        if self.api_token and self.auth_method == "header":
            headers["Authorization"] = f"Bearer {self.api_token}"
        return headers

    def _request_json(self, url: str) -> Tuple[int, Dict[str, str], Optional[Any]]:
        """Make HTTP request and parse JSON response with caching.

        Args:
            url: Target URL

        Returns:
            Tuple of (status_code, headers_dict, parsed_json_or_none)
        """
        key = self._cache_key(url)
        # In-run cache
        cached = self._cache.get(key)
        if cached:
            self._cache_hit("memory", url)
            return int(cached.get("status", 200)), dict(cached.get("headers", {})), cached.get("data")

        # File cache
        fval = self._file_get(key)
        if fval:
            self._cache_hit("file", url)
            # Promote into memory cache with a short TTL to avoid repeated file reads
            try:
                ttl = int(Constants.OSM_CACHE_TTL_SEC)  # type: ignore[attr-defined]
            except Exception:  # pylint: disable=broad-exception-caught
                ttl = 60
            self._cache.set(key, fval, ttl)
            return int(fval.get("status", 200)), dict(fval.get("headers", {})), fval.get("data")

        self._cache_miss("both", url)
        if is_debug_enabled(logger):
            logger.debug(
                "OpenSourceMalware request",
                extra=extra_context(
                    event="osm_request",
                    component="osm_client",
                    target=safe_url(url),
                ),
            )

        headers = self._build_headers()
        with Timer() as t:
            # Note: robust_get hardcodes context="robust_get", so we can't override it
            status, response_headers, text = robust_get(url, headers=headers)

        if status != 200 or not isinstance(text, str):
            logger.info(
                "OpenSourceMalware response non-200",
                extra=extra_context(
                    event="osm_response",
                    component="osm_client",
                    outcome="non_200",
                    status_code=status,
                    duration_ms=t.duration_ms() if "t" in locals() else None,
                    target=safe_url(url),
                ),
            )
            return status, response_headers, None

        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            return status, response_headers, None

        # Cache successful responses
        cache_record = {"status": status, "headers": response_headers, "data": data}
        try:
            self._cache.set(key, cache_record, int(self.cache_ttl_sec))
            self._file_put(key, cache_record, int(self.cache_ttl_sec))
        except Exception:  # pylint: disable=broad-exception-caught
            pass

        logger.info(
            "OpenSourceMalware response ok",
            extra=extra_context(
                event="osm_response",
                component="osm_client",
                outcome="success",
                status_code=status,
                duration_ms=t.duration_ms(),
                target=safe_url(url),
            ),
        )
        increment(SERVICE, "attempts_total")
        return status, response_headers, data

    def check_package(
        self, ecosystem: str, name: str, version: Optional[str] = None
    ) -> Tuple[int, Dict[str, str], Optional[Dict[str, Any]]]:
        """Check if a package is malicious.

        Args:
            ecosystem: Package ecosystem (npm, pypi, maven, rubygems)
            name: Package name
            version: Optional package version

        Returns:
            Tuple of (status_code, headers_dict, response_json_or_none)
        """
        url = self._build_url(ecosystem, name, version)
        return self._request_json(url)
