"""Integration tests for P0fV2Provider against the vendored .fp data."""
from __future__ import annotations

from pathlib import Path

import pytest

from decnet.prober.osfp import factory, get_all_providers, get_provider
from decnet.prober.osfp.base import OsMatch
from decnet.prober.osfp.p0f.provider import P0fV2Provider


@pytest.fixture(autouse=True)
def _reset_factory_cache():
    """Clean singleton between tests so env overrides take effect."""
    factory.reset_cache()
    yield
    factory.reset_cache()


# ─── Provider-level end-to-end ───────────────────────────────────────────────


def test_provider_loads_all_four_contexts() -> None:
    p = P0fV2Provider()
    counts = p.signature_counts()
    assert counts["syn"] == 262, counts
    assert counts["synack"] == 61, counts
    assert counts["rst"] == 46, counts
    assert counts["stray"] == 6, counts


def test_match_known_linux_26_signature() -> None:
    """Linux 2.6 with window=5840, MSS=1460, wscale=7 is in the
    vendored p0f.fp — must resolve to a Linux match."""
    p = P0fV2Provider()
    obs = {
        "window": 5840, "ttl": 64, "df": True, "total_len": 60,
        "options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
        "mss": 1460, "wscale": 7, "context": "syn",
    }
    match = p.match(obs)
    assert match is not None
    assert match.os == "Linux"
    assert match.provider == "p0f-v2"
    assert match.confidence > 0.5


def test_match_returns_none_for_unmatchable_observation() -> None:
    p = P0fV2Provider()
    # Ridiculous values with no corresponding signature.
    obs = {
        "window": 999999, "ttl": 64, "df": True, "total_len": 9999,
        "options_sig": "?255,?254", "quirks": frozenset(),
        "mss": 9999, "wscale": 99, "context": "syn",
    }
    assert p.match(obs) is None


def test_match_unknown_context_returns_none() -> None:
    p = P0fV2Provider()
    obs = {"window": 5840, "ttl": 64, "df": True, "total_len": 60,
           "options_sig": "M1460", "quirks": frozenset(),
           "mss": 1460, "context": "impossible"}
    assert p.match(obs) is None


def test_match_missing_context_defaults_to_syn() -> None:
    p = P0fV2Provider()
    obs = {
        "window": 5840, "ttl": 64, "df": True, "total_len": 60,
        "options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
        "mss": 1460, "wscale": 7,
        # no 'context' key
    }
    match = p.match(obs)
    assert match is not None
    assert match.os == "Linux"


def test_match_synack_context_uses_p0fa() -> None:
    """Sanity: active-probe SYN-ACK observations resolve against the
    61-sig p0fa.fp list, not the 262-sig p0f.fp list.

    Targeting "S22:64:1:60:M*,S,T,N,W0:AT:Linux:2.2" from p0fa.fp
    (ACK quirk + second-timestamp quirk are characteristic of SYN-ACK
    responses, distinguishing these sigs from the plain-SYN DB)."""
    p = P0fV2Provider()
    obs = {
        "window": 22 * 1460, "ttl": 64, "df": True, "total_len": 60,
        "options_sig": "M1460,S,T,N,W0",
        "quirks": frozenset({"A", "T"}),   # ACK-nonzero + T2-nonzero
        "mss": 1460, "wscale": 0, "context": "synack",
    }
    match = p.match(obs)
    assert match is not None
    assert match.os == "Linux"


def test_match_returns_highest_specificity_not_first() -> None:
    """When multiple signatures can fire, the provider must pick the
    most-specific one. Proxy for this: a Linux-style observation that
    could be caught by an @generic fallback AND a literal-Linux sig must
    resolve to the literal one (higher confidence)."""
    p = P0fV2Provider()
    obs = {
        "window": 5840, "ttl": 64, "df": True, "total_len": 60,
        "options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
        "mss": 1460, "wscale": 7, "context": "syn",
    }
    match = p.match(obs)
    # An @generic match would carry is_approximate=True on the underlying
    # signature — we can't check that through OsMatch directly, but we can
    # check confidence: literal-heavy sigs score notably higher than the
    # wildcard-heavy @-fallbacks, so a healthy match is ≥ 0.6.
    assert match is not None
    assert match.confidence >= 0.6


# ─── Factory dispatch ───────────────────────────────────────────────────────


def test_factory_default_is_p0f_v2() -> None:
    p = get_provider()
    assert p.name == "p0f-v2"
    assert isinstance(p, P0fV2Provider)


def test_factory_is_memoised() -> None:
    assert get_provider() is get_provider()


def test_factory_get_all_providers_returns_list() -> None:
    providers = get_all_providers()
    assert len(providers) >= 1
    assert providers[0].name == "p0f-v2"


def test_factory_env_override_chain(monkeypatch: pytest.MonkeyPatch) -> None:
    """Multi-provider chain must preserve declared order."""
    monkeypatch.setenv("DECNET_OSFP_PROVIDERS", "p0f-v2")
    factory.reset_cache()
    providers = get_all_providers()
    assert [p.name for p in providers] == ["p0f-v2"]


def test_factory_unsupported_name_raises(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("DECNET_OSFP_PROVIDERS", "nonexistent-source")
    factory.reset_cache()
    with pytest.raises(ValueError):
        get_provider()


def test_factory_reserved_names_raise_not_implemented(monkeypatch: pytest.MonkeyPatch) -> None:
    """nmap-osdb and decnet-observed are reserved for future work; the
    factory must fail loud rather than silently."""
    for reserved in ("nmap-osdb", "decnet-observed"):
        monkeypatch.setenv("DECNET_OSFP_PROVIDERS", reserved)
        factory.reset_cache()
        with pytest.raises(NotImplementedError):
            get_provider()


# ─── OsMatch surface ────────────────────────────────────────────────────────


def test_osmatch_str_shows_provider() -> None:
    match = OsMatch(os="Linux", flavor="2.6", confidence=0.8, provider="p0f-v2")
    s = str(match)
    assert "Linux" in s and "2.6" in s and "p0f-v2" in s


def test_osmatch_userland_flag_marks_scanner() -> None:
    match = OsMatch(os="nmap", flavor="syn-stealth", confidence=0.9,
                    provider="p0f-v2", is_userland=True)
    assert match.is_userland
    assert "userland" in str(match).lower()
