#!/usr/bin/env python3
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Shared Debian Code Search (v1 API) client for the offline codesearch aids.

Used by both rank-license-usage-codesearch.py and verify-anchors-codesearch.py
so the politeness and caching behaviour cannot drift between them. Politeness:
one request at a time, a sleep between live requests, an on-disk response cache,
a caller-supplied User-Agent, honouring HTTP 429 / Retry-After, and retrying a
stalled connection. The v1 API needs an `x-dcs-apikey` header (key via
`$DCS_API_KEY` or a `--key-file`; see load_key).

These are offline curation aids - NOT part of the test suite's network path and
never run in CI. This module imports only the stdlib, so it stays a leaf that
both scripts can depend on.
"""

from __future__ import annotations

import enum
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from http import HTTPStatus
from pathlib import Path

API_URL = "https://codesearch.debian.net/api/v1/search"
DEFAULT_SLEEP = 1.3  # seconds between LIVE requests (>= 1 req/s with margin)
# Above this a result set is broad-by-design; do not download it (may be GBs),
# just treat the candidate as clearly over threshold.
MAX_RESPONSE_BYTES = 25_000_000
MAX_ATTEMPTS = 4  # tries per query before giving up on it
BACKOFF_STEP = 2.0  # seconds; multiplied by the attempt number
REQUEST_TIMEOUT = 120  # seconds for a single urlopen
ERROR_BODY_CHARS = 120  # of a fatal error's body to include in the message
CACHE_KEY_LEN = 20  # hex chars of the query digest used as the cache filename


class SearchState(enum.Enum):
    HITS = "hits"  # a normal result set (see SearchOutcome.hits)
    BROAD = "broad"  # too many results to download; treated as clearly popular
    PENDING = "pending"  # offline and nothing cached yet; not queried


@dataclass(frozen=True)
class SearchOutcome:
    state: SearchState
    hits: list[dict] = field(default_factory=list)


class SearchError(Exception):
    """A single query failed after exhausting its retries.

    Recoverable at the batch level - the caller skips the candidate and keeps
    going. Distinct from a fatal error (bad key, malformed query), which raises
    SystemExit and aborts the whole run.
    """


def load_key(path: Path | None) -> str:
    """API key from $DCS_API_KEY, else the given --key-file, else abort."""
    env = os.environ.get("DCS_API_KEY")
    if env:
        return env.strip()
    if path is not None:
        return path.read_text(encoding="utf-8").strip()
    msg = (
        "No API key: set $DCS_API_KEY or pass --key-file PATH "
        "(get one at https://codesearch.debian.net/apikeys/)."
    )
    raise SystemExit(msg)


def _http_should_retry(code: int) -> bool:
    """Whether an HTTP status warrants a retry (rate-limit or server error)."""
    return (
        code == HTTPStatus.TOO_MANY_REQUESTS or code >= HTTPStatus.INTERNAL_SERVER_ERROR
    )


def _retry_after(exc: urllib.error.HTTPError, attempt: int) -> float:
    """Seconds to wait: the server's Retry-After if given, else linear backoff."""
    retry_after = exc.headers.get("Retry-After")
    if retry_after:
        return float(retry_after)
    return BACKOFF_STEP * (attempt + 1)


def _backoff(seconds: float, label: str) -> None:
    print(f"  {label}; backing off {seconds:.0f}s", file=sys.stderr)
    time.sleep(seconds)


@dataclass
class DcsConfig:
    """Connection, cache and politeness settings for a DcsClient.

    ``cache_prefix`` namespaces the cache key so two scripts sharing a cache
    directory never collide (e.g. "rank" vs the empty default).
    """

    key: str
    user_agent: str
    cache_dir: Path
    sleep: float = DEFAULT_SLEEP
    refresh: bool = False
    cache_prefix: str = ""


class DcsClient:
    def __init__(self, config: DcsConfig) -> None:
        self.key = config.key
        self.user_agent = config.user_agent
        self.cache_dir = config.cache_dir
        self.sleep = config.sleep
        self.refresh = config.refresh
        self.cache_prefix = config.cache_prefix
        self._last_live = 0.0
        config.cache_dir.mkdir(parents=True, exist_ok=True)

    def _cache_path(self, query: str, match_mode: str) -> Path:
        parts = [match_mode, query]
        if self.cache_prefix:
            parts.insert(0, self.cache_prefix)
        digest = hashlib.sha256("\x00".join(parts).encode()).hexdigest()
        return self.cache_dir / f"{digest[:CACHE_KEY_LEN]}.json"

    def _read_cache(self, path: Path) -> SearchOutcome | None:
        """Cached outcome, or None on miss/refresh/corruption (a corrupt file is
        deleted so the next call refetches). The on-disk shape is unchanged:
        a JSON list is HITS; the legacy ``{"broad": true}`` object is BROAD."""
        if self.refresh or not path.exists():
            return None
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            path.unlink(missing_ok=True)
            return None
        if isinstance(data, dict):
            return SearchOutcome(SearchState.BROAD)
        return SearchOutcome(SearchState.HITS, data)

    def _write_cache(self, path: Path, outcome: SearchOutcome) -> None:
        # Keep the legacy on-disk shape so existing cache files stay valid.
        data: list[dict] | dict = (
            {"broad": True}
            if outcome.state is SearchState.BROAD
            else list(outcome.hits)
        )
        tmp = path.with_name(path.name + ".tmp")
        tmp.write_text(json.dumps(data), encoding="utf-8")
        tmp.replace(path)  # atomic; a killed run cannot leave a half file

    def _build_request(self, query: str, match_mode: str) -> urllib.request.Request:
        params = urllib.parse.urlencode({"query": query, "match_mode": match_mode})
        return urllib.request.Request(  # noqa: S310
            f"{API_URL}?{params}",
            headers={"User-Agent": self.user_agent, "x-dcs-apikey": self.key},
        )

    def search_cached(self, query: str, match_mode: str) -> SearchOutcome:
        """Cached outcome, or PENDING when nothing is cached. No network, no key."""
        cached = self._read_cache(self._cache_path(query, match_mode))
        return cached if cached is not None else SearchOutcome(SearchState.PENDING)

    def search_live(self, query: str, match_mode: str) -> SearchOutcome:
        """Cached outcome, or a fresh fetch that is then cached."""
        path = self._cache_path(query, match_mode)
        cached = self._read_cache(path)
        if cached is not None:
            return cached
        outcome = self._fetch(self._build_request(query, match_mode))
        self._write_cache(path, outcome)
        return outcome

    def _throttle(self) -> None:
        wait = self.sleep - (time.monotonic() - self._last_live)
        if wait > 0:
            time.sleep(wait)

    def _read(self, req: urllib.request.Request) -> SearchOutcome:
        with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:  # noqa: S310
            raw = resp.read(MAX_RESPONSE_BYTES + 1)
        self._last_live = time.monotonic()
        if len(raw) > MAX_RESPONSE_BYTES:
            return SearchOutcome(SearchState.BROAD)
        return SearchOutcome(SearchState.HITS, json.loads(raw.decode()))

    def _fetch(self, req: urllib.request.Request) -> SearchOutcome:
        """Fetch with retries; SearchError on exhaustion, SystemExit if fatal.

        A rate-limit/server error or a stalled connection is retried; any
        other HTTP error (bad key, malformed query) is fatal and aborts.
        """
        for attempt in range(MAX_ATTEMPTS):
            self._throttle()
            try:
                return self._read(req)
            except urllib.error.HTTPError as exc:
                self._last_live = time.monotonic()
                if not _http_should_retry(exc.code):
                    body = exc.read().decode(errors="replace")[:ERROR_BODY_CHARS]
                    msg = f"HTTP {exc.code} from Code Search: {body}"
                    raise SystemExit(msg) from exc
                _backoff(_retry_after(exc, attempt), f"HTTP {exc.code}")
            except (TimeoutError, urllib.error.URLError) as exc:
                # A stalled/reset connection carries no HTTP status to branch
                # on: back off and retry the same way as a 429/5xx.
                self._last_live = time.monotonic()
                _backoff(BACKOFF_STEP * (attempt + 1), repr(exc))
        msg = "Code Search kept failing after retries; aborting."
        raise SearchError(msg)
