#!/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.

"""Behaviour tests for scripts/rank-license-usage-codesearch.py.

The script's filename has hyphens, so it is loaded through the CF0 harness
(tests/dcs_helpers.load_script) rather than imported by name. Network is
faked with FakeTransport; time.sleep is neutralised so retry/backoff is fast.
"""

from __future__ import annotations

import sys
import time
import urllib.request
from pathlib import Path
from typing import Any

import pytest

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))

import dcs_client  # noqa: E402

from tests.dcs_helpers import FakeTransport, load_script, make_http_error  # noqa: E402

RANK = load_script("rank-license-usage-codesearch.py")

AUTH_FORBIDDEN = 403
BAD_REQUEST = 400
SERVER_ERROR = 503


@pytest.fixture(autouse=True)
def _no_real_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(time, "sleep", lambda *_: None)


# RANK is a dynamically loaded module, so its symbols are untyped (Any).
def _client(cache_dir: Path, *, refresh: bool = True) -> Any:
    config = dcs_client.DcsConfig(
        key="key",
        user_agent="test-agent",
        cache_dir=cache_dir,
        sleep=0.0,
        refresh=refresh,
    )
    return dcs_client.DcsClient(config)


def _candidate() -> Any:
    return RANK.Candidate(label="X", query="q", match_mode="literal")


def _use_transport(monkeypatch: pytest.MonkeyPatch, transport: FakeTransport) -> None:
    monkeypatch.setattr(urllib.request, "urlopen", transport)


def test_retryable_exhausted_raises_searcherror(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    transport = FakeTransport([make_http_error(SERVER_ERROR)], repeat=True)
    _use_transport(monkeypatch, transport)
    client = _client(tmp_path)
    with pytest.raises(dcs_client.SearchError):
        client.search_live("q", "literal")
    assert transport.calls == dcs_client.MAX_ATTEMPTS


def test_rank_skips_exhausted_candidate(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    transport = FakeTransport([make_http_error(SERVER_ERROR)], repeat=True)
    _use_transport(monkeypatch, transport)
    client = _client(tmp_path)
    # A retry-exhausted candidate is skipped, not fatal: rank() returns [].
    assert RANK.fetch_results(client.search_live, [_candidate()], progress=True) == []


def test_auth_failure_is_fatal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    transport = FakeTransport([make_http_error(AUTH_FORBIDDEN)], repeat=True)
    _use_transport(monkeypatch, transport)
    client = _client(tmp_path)
    with pytest.raises(SystemExit):
        RANK.fetch_results(
            client.search_live, [_candidate(), _candidate()], progress=True
        )
    # Aborted on the first request rather than retried per candidate.
    assert transport.calls == 1


def test_bad_query_is_fatal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    transport = FakeTransport([make_http_error(BAD_REQUEST)], repeat=True)
    _use_transport(monkeypatch, transport)
    client = _client(tmp_path)
    with pytest.raises(SystemExit):
        RANK.fetch_results(client.search_live, [_candidate()], progress=True)
    assert transport.calls == 1


def test_offline_report_uses_cache_only(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    # A transport that raises if touched proves search_cached never hits the net.
    _use_transport(monkeypatch, FakeTransport([]))
    client = _client(tmp_path, refresh=False)
    candidate = _candidate()
    outcome = dcs_client.SearchOutcome(dcs_client.SearchState.HITS, [{"package": "p"}])
    path = client._cache_path(candidate.query, candidate.match_mode)
    client._write_cache(path, outcome)
    results = RANK.fetch_results(client.search_cached, [candidate], progress=False)
    assert len(results) == 1
    assert results[0].state is dcs_client.SearchState.HITS
    assert results[0].packages == 1


def test_progress_reported_only_when_requested(capsys: pytest.CaptureFixture) -> None:
    # rank() decides on its `progress` argument, not on any client attribute -
    # a plain function stands in for the search here.
    def fake_search(query: str, match_mode: str) -> Any:
        return dcs_client.SearchOutcome(dcs_client.SearchState.HITS, [])

    RANK.fetch_results(fake_search, [_candidate()], progress=True)
    assert "[1/1]" in capsys.readouterr().err
    RANK.fetch_results(fake_search, [_candidate()], progress=False)
    assert "[1/1]" not in capsys.readouterr().err


def test_text_candidates_reports_skips_as_data() -> None:
    # BSD-3-Clause's distinguishing text collides with its BSD-2-Clause sibling
    # (and Artistic-1.0 in common-licenses), so it has no distinctive window.
    candidates, skipped = RANK.text_license_candidates(["BSD-2-Clause", "BSD-3-Clause"])
    assert skipped == ["BSD-3-Clause"]  # returned as data, not printed
    assert [c.label for c in candidates] == ["BSD-2-clause"]


def test_report_includes_header(capsys: pytest.CaptureFixture) -> None:
    results = [
        RANK.Result(_candidate(), state=dcs_client.SearchState.HITS, packages=3, hits=9)
    ]
    RANK.report(results, threshold=5)
    out = capsys.readouterr().out
    assert "# 1 candidate(s); threshold=5 packages" in out


def test_build_parser_defaults() -> None:
    ns = RANK.build_parser().parse_args([])
    assert ns.threshold == RANK.DEFAULT_THRESHOLD
    assert ns.max_chars is None
    assert ns.limit == 0
    assert ns.exceptions is False
    assert ns.text_mode is False


def test_text_mode_with_exceptions_rejected() -> None:
    # The conflict is enforced by the parser (mutually-exclusive group),
    # not a hand-rolled check inside main().
    with pytest.raises(SystemExit):
        RANK.build_parser().parse_args(["--text-mode", "--exceptions"])


def test_collect_candidates_signature() -> None:
    # Called with explicit args, no argparse.Namespace.
    candidates, skipped = RANK.collect_candidates(
        ["MIT"], exceptions=False, text_mode=False
    )
    assert skipped == []
    assert [c.label for c in candidates] == ["Expat"]  # MIT -> canonical Expat


def test_limit_slices_before_build(monkeypatch: pytest.MonkeyPatch) -> None:
    # The costly window-builder must run for at most `limit` ids, not for all
    # of them - i.e. the limit slices ids before the build, not after.
    calls = 0
    real = RANK.distinctive_window

    def spy(*args: Any, **kwargs: Any) -> Any:
        nonlocal calls
        calls += 1
        return real(*args, **kwargs)

    monkeypatch.setattr(RANK, "distinctive_window", spy)
    ids = ["BSD-2-Clause", "BSD-3-Clause", "ISC"]
    RANK.collect_candidates(ids, exceptions=False, text_mode=True, limit=1)
    assert calls == 1
