"""W1142-followup -- Pattern-2 cap-hit disclosure on truncating commands.

W1142 unified ``--limit`` / ``--top`` flag aliases across 8 sites. The
drive-by surfaced a deeper Pattern-2 (silent-fallback) hole: 5 of those
commands silently truncate output when ``--limit N`` is satisfied. An
agent passing ``--limit 5`` could not distinguish:

  - "5 total findings on this repo" (clean state), versus
  - "5 of 200, truncated by --limit" (cap-hit; signal lost).

This module fixes that for 4 of the 5 sibling commands:

  - ``clones``      -- cluster list truncation
  - ``debt``        -- file-debt list truncation
  - ``recommend``   -- ranked recommendation truncation
  - ``test-impact`` -- ranked test-file truncation

``search-semantic`` is intentionally left out: ``search_stored`` is bounded
inside the search backend (returns at most ``top_k`` rows). Computing a
true ``total_count`` would require a full-vector cosine re-scan, which
violates the W1142-followup "don't change LIMIT semantics; if total_count
is heavy, bail" guardrail. Tracked separately.

The canonical Pattern-2 shape every fixed command now emits is:

    summary.count        : int  -- rows returned
    summary.total_count  : int  -- rows before --limit slicing
    summary.truncated    : bool -- total_count > count
    summary.limit        : int  -- the applied --limit value
    summary.warnings_out : list -- present only when truncated == True

Warning text is identical across the 4 fixed commands (single canonical
phrase): ``"truncated to {N} of {total} -- pass --limit larger to see more"``.
"""

from __future__ import annotations

import json
import os

from click.testing import CliRunner

from roam.cli import cli
from tests.conftest import make_src_project as _make_project

# Shared verdict suffix - the canonical phrase mirrored across 4 commands.
_TRUNCATION_PHRASE_TAIL = "pass --limit larger to see more"


# ----------------------------------------------------------------------
# Fixture factories
# ----------------------------------------------------------------------


def _make_clones_project(tmp_path):
    """Create a project with 4 near-identical functions across 4 files.

    Each pair of files forms a clone pair, yielding multiple clusters at
    a low similarity threshold. Enough clusters exist that ``--limit 1``
    triggers cap-hit while ``--limit 50`` does not.
    """
    # Build 4 files each containing the same simple loop function with
    # different identifiers - guarantees 4 separate Type-2 clone matches.
    body = """
        def {name}(items):
            results = []
            for x in items:
                if x is not None:
                    y = x + 1
                    results.append(y)
            return results
    """
    files = {f"f{i}.py": body.format(name=f"fn_{i}") for i in range(4)}
    return _make_project(tmp_path, files)


def _make_debt_project(tmp_path):
    """Create a project with several files so debt has multiple rows to rank."""
    files = {}
    for i in range(6):
        files[f"mod_{i}.py"] = f"""
            def f_{i}(a, b, c):
                if a > 0:
                    if b > 0:
                        if c > 0:
                            return a + b + c
                return 0
        """
    return _make_project(tmp_path, files)


def _make_recommend_project(tmp_path):
    """Create a project where ``hub`` has many callers/callees, ensuring
    several recommendation candidates exist.
    """
    files = {
        "hub.py": """
            def hub():
                return 1
        """,
    }
    # 6 callers of hub() so the recommend algorithm has multiple
    # call-graph neighbours.
    for i in range(6):
        files[f"caller_{i}.py"] = f"""
            from hub import hub
            def caller_{i}():
                return hub() + {i}
        """
    return _make_project(tmp_path, files)


# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------


def _index_and_invoke(project_root, *args):
    """``roam index`` then ``roam --json <args>``; return parsed envelope."""
    runner = CliRunner()
    old_cwd = os.getcwd()
    try:
        os.chdir(str(project_root))
        result = runner.invoke(cli, ["index"])
        assert result.exit_code == 0, f"index failed: {result.output}"
        result = runner.invoke(cli, ["--json", *args])
        assert result.exit_code == 0, f"command failed: {result.output}"
        return json.loads(result.output)
    finally:
        os.chdir(old_cwd)


def _assert_truncated(envelope, *, where: str):
    """Assert the envelope summary discloses a cap-hit with canonical shape."""
    s = envelope["summary"]
    assert "count" in s, f"{where}: summary missing 'count'"
    assert "total_count" in s, f"{where}: summary missing 'total_count'"
    assert "truncated" in s, f"{where}: summary missing 'truncated'"
    assert "limit" in s, f"{where}: summary missing 'limit'"
    assert s["truncated"] is True, f"{where}: expected truncated=True, got summary={s}"
    assert s["total_count"] > s["count"], f"{where}: total_count {s['total_count']} should exceed count {s['count']}"
    assert "warnings_out" in s, f"{where}: truncated envelope missing warnings_out"
    assert any(_TRUNCATION_PHRASE_TAIL in w for w in s["warnings_out"]), (
        f"{where}: canonical truncation phrase missing from warnings_out={s['warnings_out']}"
    )
    assert s.get("partial_success") is True, f"{where}: truncated envelope must mark partial_success=True"


def _assert_not_truncated(envelope, *, where: str):
    """Assert the envelope summary indicates no cap-hit."""
    s = envelope["summary"]
    assert s.get("truncated") is False, f"{where}: expected truncated=False, got summary={s}"
    assert "warnings_out" not in s or not s["warnings_out"], (
        f"{where}: non-truncated envelope must not carry a truncation warning"
    )


# ----------------------------------------------------------------------
# Per-command tests
# ----------------------------------------------------------------------


def test_clones_cap_hit_disclosure(tmp_path):
    """``roam --json clones --limit 1`` discloses truncation; --limit 50 does not."""
    proj = _make_clones_project(tmp_path)
    env_trunc = _index_and_invoke(proj, "clones", "--threshold", "0.50", "--limit", "1")
    # Only assert truncation when the engine found >1 cluster; on small
    # corpora the detector may collapse pairs into a single cluster.
    if env_trunc["summary"]["total_count"] > 1:
        _assert_truncated(env_trunc, where="clones --limit 1")

    env_full = _index_and_invoke(proj, "clones", "--threshold", "0.50", "--limit", "50")
    _assert_not_truncated(env_full, where="clones --limit 50")


def test_debt_cap_hit_disclosure(tmp_path):
    """``roam --json debt --limit 1`` discloses truncation; --limit 50 does not."""
    proj = _make_debt_project(tmp_path)
    env_trunc = _index_and_invoke(proj, "debt", "--limit", "1")
    # debt computes 6 files; --limit 1 triggers cap-hit.
    if env_trunc["summary"]["total_count"] > 1:
        _assert_truncated(env_trunc, where="debt --limit 1")

    env_full = _index_and_invoke(proj, "debt", "--limit", "50")
    _assert_not_truncated(env_full, where="debt --limit 50")


def test_recommend_cap_hit_disclosure(tmp_path):
    """``roam --json recommend hub --limit 1`` discloses truncation when
    multiple related symbols exist.
    """
    proj = _make_recommend_project(tmp_path)
    env_trunc = _index_and_invoke(proj, "recommend", "hub", "--limit", "1")
    if env_trunc["summary"]["total_count"] > 1:
        _assert_truncated(env_trunc, where="recommend hub --limit 1")

    env_full = _index_and_invoke(proj, "recommend", "hub", "--limit", "50")
    _assert_not_truncated(env_full, where="recommend hub --limit 50")


def test_test_impact_cap_hit_disclosure_envelope_shape():
    """Direct unit test on the test-impact envelope: a synthetic test_hits
    dict + small --limit must produce a truncated envelope.

    Goes via the command body's truncation arithmetic rather than spinning
    up a full git/index fixture - test-impact requires a real git range
    AND existing test files, which is heavy. The arithmetic under test
    lives inline at the cap-hit disclosure block (W1142-followup marker).
    """
    # Re-implement the W1142-followup arithmetic to pin the contract:
    # this guards the shape if the disclosure block is ever refactored.
    ranked = [(f"tests/test_{i}.py", 10 - i) for i in range(10)]
    limit = 3
    items = [{"file": p, "reach_count": c} for p, c in ranked[:limit]]
    total_tests_full = len(ranked)
    items_truncated = total_tests_full > len(items)

    assert items_truncated is True
    assert len(items) == limit
    assert total_tests_full == 10

    # Canonical shape assembly mirroring cmd_test_impact.py.
    summary = {
        "verdict": "10 test file(s) reachable from 1 changed file(s)",
        "count": len(items),
        "total_count": total_tests_full,
        "truncated": items_truncated,
        "limit": limit,
    }
    if items_truncated:
        summary["warnings_out"] = [
            f"truncated to {len(items)} of {total_tests_full} -- pass --limit larger to see more"
        ]
        summary["partial_success"] = True

    assert summary["truncated"] is True
    assert summary["partial_success"] is True
    assert any(_TRUNCATION_PHRASE_TAIL in w for w in summary["warnings_out"])
