"""Tests for the near-duplicate finder (#2088).

The SQL half needs a database and lives in the integration lane; what's covered
here is the grouping rule — which is where the interesting decisions are — plus
the fail-open contract and the threshold resolution.
"""
from unittest.mock import AsyncMock, patch

import pytest

from scribe.services import dedup as dedup_svc
from scribe.services.dedup import (
    DUPLICATE_DEFAULT_THRESHOLDS,
    get_duplicate_threshold,
    group_pairs,
)


# --- grouping -------------------------------------------------------------


def test_a_simple_pair_becomes_one_set():
    assert group_pairs([(1, 2, 0.9)]) == [[1, 2]]


def test_grouping_is_transitive():
    """A~B and B~C puts all three in one set even though A and C never cleared
    the bar together. That mirrors merge, which folds every source into one
    survivor — three overlapping pairs would just be the same chore, unsorted."""
    assert group_pairs([(1, 2, 0.9), (2, 3, 0.9)]) == [[1, 2, 3]]


def test_disjoint_clusters_stay_separate():
    groups = group_pairs([(1, 2, 0.9), (3, 4, 0.9)])
    assert groups == [[1, 2], [3, 4]]


def test_largest_cluster_comes_first():
    """The most tangled thing is the most worth fixing."""
    groups = group_pairs([(5, 6, 0.9), (1, 2, 0.9), (2, 3, 0.9), (3, 4, 0.9)])
    assert groups[0] == [1, 2, 3, 4]


def test_output_is_stable_across_input_order():
    """A report that reshuffles between runs is one nobody can work through."""
    a = group_pairs([(1, 2, 0.9), (2, 3, 0.9), (7, 8, 0.9)])
    b = group_pairs([(7, 8, 0.9), (2, 3, 0.9), (1, 2, 0.9)])
    assert a == b


def test_no_pairs_means_no_groups():
    assert group_pairs([]) == []


def test_a_node_never_forms_a_group_with_itself():
    """The SQL uses `note_id <` so this shouldn't arrive, but a singleton set
    would render as a "duplicate" of nothing and offer an impossible merge."""
    assert group_pairs([(1, 1, 1.0)]) == []


# --- threshold ------------------------------------------------------------


@pytest.mark.parametrize("kind", ["snippet", "note", "task"])
async def test_threshold_falls_back_to_the_per_kind_default_when_unset(kind):
    default = DUPLICATE_DEFAULT_THRESHOLDS[kind]
    with patch("scribe.services.settings.get_setting",
               AsyncMock(return_value=str(default))):
        assert await get_duplicate_threshold(1, kind) == default


async def test_each_kind_reads_its_own_settings_key():
    """Tuning the note floor must not move the snippet report — the whole
    point of splitting the setting is that the kinds calibrate independently."""
    get = AsyncMock(return_value="0.5")
    with patch("scribe.services.settings.get_setting", get):
        await get_duplicate_threshold(1, "note")
    key, default = get.await_args.args[1], get.await_args.args[2]
    assert key == "kb_duplicate_threshold_note"
    assert default == str(DUPLICATE_DEFAULT_THRESHOLDS["note"])


async def test_a_garbage_setting_falls_back_rather_than_raising():
    with patch("scribe.services.settings.get_setting",
               AsyncMock(return_value="not-a-number")):
        assert (await get_duplicate_threshold(1, "snippet")
                == DUPLICATE_DEFAULT_THRESHOLDS["snippet"])


@pytest.mark.parametrize("stored, expected", [("2.5", 1.0), ("-3", 0.0)])
async def test_threshold_is_clamped_to_the_valid_range(stored, expected):
    with patch("scribe.services.settings.get_setting", AsyncMock(return_value=stored)):
        assert await get_duplicate_threshold(1, "snippet") == expected


def test_the_snippet_report_threshold_is_looser_than_the_write_gate():
    """The gate BLOCKS a create and must be unforgiving of noise; the snippet
    report only suggests a merge the operator reviews, so it has to reach
    further or it would never surface the pairs the gate already let through.
    Snippet-only on purpose: note/task floors sit ABOVE the gate because chunk
    grain (#280) lifts related families over it — see the module comment."""
    assert DUPLICATE_DEFAULT_THRESHOLDS["snippet"] < dedup_svc._SEMANTIC_THRESHOLD


def test_the_note_and_task_floors_are_stricter_than_the_snippet_floor():
    """At chunk grain, 0.82 is a related-families view; the report's default
    must point at genuinely-alike records (measured 2026-08-09, note #2551)."""
    assert DUPLICATE_DEFAULT_THRESHOLDS["note"] == 0.93
    assert DUPLICATE_DEFAULT_THRESHOLDS["task"] == 0.93


# --- fail-open ------------------------------------------------------------


async def test_a_failed_scan_returns_an_empty_report_not_an_error():
    """A suggestion feature must not be able to break the page it decorates."""
    with (
        patch.object(dedup_svc, "get_duplicate_threshold", AsyncMock(return_value=0.8)),
        patch.object(dedup_svc, "async_session", side_effect=RuntimeError("boom")),
    ):
        out = await dedup_svc.find_duplicate_snippets(1)
    # The empty report still carries its per-kind `suggestion` (#2547) — a
    # failed scan must be indistinguishable in SHAPE from a clean one, or every
    # consumer needs a second code path for the degraded case.
    assert out["groups"] == [] and out["pairs"] == []
    assert out["threshold"] == 0.8
    assert "merge" in out["suggestion"]
