"""Tests for CLI command modules - field extraction from API responses.

Each test creates a mock API response matching the actual Rust DTO and verifies
the CLI command extracts and formats fields correctly.
"""
from __future__ import annotations

import io
import sys
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock

import pytest


# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------

def _make_args(**kwargs) -> SimpleNamespace:
    """Create a fake args namespace with defaults."""
    defaults = {"format": "table"}
    defaults.update(kwargs)
    return SimpleNamespace(**defaults)


def _make_client(responses: dict[str, Any] | None = None) -> MagicMock:
    """Create a mock HTTP client that returns preset responses.

    Args:
        responses: mapping of method -> response data, or a single response
                   for all methods.
    """
    client = MagicMock()
    if responses is None:
        responses = {}

    def _get(path, params=None):
        return responses.get("get", responses.get("default", {}))

    def _post(path, body=None):
        return responses.get("post", responses.get("default", {}))

    def _delete(path):
        return responses.get("delete", responses.get("default", {}))

    client.get = MagicMock(side_effect=_get)
    client.post = MagicMock(side_effect=_post)
    client.delete = MagicMock(side_effect=_delete)
    return client


def _capture_stdout(fn, *args, **kwargs) -> str:
    """Capture stdout output from a function call."""
    buf = io.StringIO()
    old = sys.stdout
    sys.stdout = buf
    try:
        fn(*args, **kwargs)
    finally:
        sys.stdout = old
    return buf.getvalue()


# ===========================================================================
# entity.py tests
# ===========================================================================

class TestEntitySearch:
    """Tests for entity search - EntitySearchResponse DTO."""

    def test_search_extracts_data_field(self):
        """The API returns {data: [...], total: N}. CLI must read 'data' not 'entities'."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "data": [
                {
                    "kind": "Canonical",
                    "entity": {
                        "id": "aaaaaaaa-1111-2222-3333-444444444444",
                        "entity_type": "person",
                        "confidence_score": 0.95,
                        "updated_at": "2026-02-26T10:00:00Z",
                    },
                }
            ],
            "total": 1,
        }
        client = _make_client({"get": resp})
        args = _make_args(
            action="search", query="test", limit=10, offset=0,
            name=None, email=None, phone=None, source=None, entity_type=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "person" in output
        assert "0.95" in output
        # Should NOT show "No results" or empty table
        assert "(no results)" not in output

    def test_search_handles_empty_data(self):
        """Empty data array should show empty table."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"data": [], "total": 0}
        client = _make_client({"get": resp})
        args = _make_args(
            action="search", query="nonexistent", limit=10, offset=0,
            name=None, email=None, phone=None, source=None, entity_type=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "(no results)" in output

    def test_search_truncates_long_uuids(self):
        """UUIDs longer than 12 chars should be truncated."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "data": [{
                "kind": "Canonical",
                "entity": {
                    "id": "abcdefgh-1234-5678-9012-abcdefghijkl",
                    "entity_type": "company",
                    "confidence_score": 0.8,
                    "updated_at": "2026-01-01T00:00:00Z",
                },
            }],
            "total": 1,
        }
        client = _make_client({"get": resp})
        args = _make_args(
            action="search", query="test", limit=10, offset=0,
            name=None, email=None, phone=None, source=None, entity_type=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "abcdefgh..." in output

    def test_search_shows_total_count(self):
        """Total count should be displayed at the bottom."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"data": [], "total": 42}
        client = _make_client({"get": resp})
        args = _make_args(
            action="search", query="test", limit=10, offset=0,
            name=None, email=None, phone=None, source=None, entity_type=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "42 total entities" in output

    def test_search_json_format(self):
        """JSON format should dump the raw response."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"data": [], "total": 0}
        client = _make_client({"get": resp})
        args = _make_args(
            action="search", format="json", query="test", limit=10, offset=0,
            name=None, email=None, phone=None, source=None, entity_type=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert '"data"' in output


class TestEntityLinked:
    """Tests for entity linked - CanonicalDetailResponse DTO."""

    def test_linked_reads_linked_entities_field(self):
        """Must read 'linked_entities' not 'records'."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "canonical": {"id": "abc", "entity_type": "person"},
            "linked_entities": [
                {
                    "id": "ext-1",
                    "data_source_id": "ds-1",
                    "source_name": "crm",
                    "external_id": "CRM-001",
                    "entity_type": "person",
                    "ingested_at": "2026-02-20T12:00:00Z",
                },
            ],
            "links": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="linked", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "crm" in output
        assert "CRM-001" in output
        assert "SOURCE" in output
        assert "EXTERNAL_ID" in output

    def test_linked_empty_shows_message(self):
        """No linked records should show informational message."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"canonical": {}, "linked_entities": [], "links": []}
        client = _make_client({"get": resp})
        args = _make_args(action="linked", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "No linked records" in output


class TestEntityHistory:
    """Tests for entity history - Vec<AuditEvent> DTO."""

    def test_history_uses_audit_event_fields(self):
        """Must use 'action' and 'timestamp', not 'event_type' and 'created_at'."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = [
            {
                "id": "aaaaaaaa-1111-2222-3333-444444444444",
                "tenant_id": "t1",
                "actor_id": "u1",
                "actor_type": "user",
                "action": "entity.merged",
                "resource_type": "canonical_entity",
                "resource_id": "abc",
                "reason": "Duplicate detected by autotune",
                "timestamp": "2026-02-25T14:30:00Z",
            }
        ]
        client = _make_client({"get": resp})
        args = _make_args(action="history", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "entity.merged" in output
        assert "Duplicate detected" in output
        assert "2026-02-25" in output

    def test_history_empty(self):
        """Empty history shows message."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = []
        client = _make_client({"get": resp})
        args = _make_args(action="history", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "No history" in output


class TestEntityMerge:
    """Tests for entity merge - direct graph mutation via POST /v1/entities/merge."""

    def test_merge_sends_entity_ids(self):
        """Merge sends entity_a_id and entity_b_id to /v1/entities/merge."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"winner_id": "e1", "loser_id": "e2", "members_moved": 3, "total_members": 5}
        client = _make_client({"post": resp})
        args = _make_args(action="merge", entity_a="e1", entity_b="e2", reason=None)
        _capture_stdout(cmd_entity, args, client)

        call_args = client.post.call_args
        path = call_args[0][0]
        body = call_args[0][1]
        assert path == "/v1/entities/merge"
        assert body["entity_a_id"] == "e1"
        assert body["entity_b_id"] == "e2"
        assert "reason" not in body

    def test_merge_includes_reason(self):
        """Reason should be included in body when provided."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"winner_id": "e1", "loser_id": "e2", "members_moved": 3, "total_members": 5}
        client = _make_client({"post": resp})
        args = _make_args(
            action="merge", entity_a="e1", entity_b="e2", reason="Manual fix",
        )
        _capture_stdout(cmd_entity, args, client)

        body = client.post.call_args[0][1]
        assert body["reason"] == "Manual fix"

    def test_merge_displays_winner(self):
        """Output should show winner, members moved, total."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"winner_id": "w-123", "loser_id": "l-456", "members_moved": 2, "total_members": 7}
        client = _make_client({"post": resp})
        args = _make_args(action="merge", entity_a="e1", entity_b="e2", reason=None)
        output = _capture_stdout(cmd_entity, args, client)
        assert "w-123" in output
        assert "2" in output
        assert "7" in output

    def test_merge_json_format(self):
        """JSON format should dump raw response."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"winner_id": "w", "loser_id": "l", "members_moved": 0, "total_members": 1}
        client = _make_client({"post": resp})
        args = _make_args(action="merge", entity_a="e1", entity_b="e2", reason=None, format="json")
        output = _capture_stdout(cmd_entity, args, client)
        assert '"winner_id"' in output


class TestEntitySplit:
    """Tests for entity split - direct graph mutation via POST /v1/entities/split."""

    def test_split_sends_canonical_and_member_ids(self):
        """Split sends canonical_id and member_ids to /v1/entities/split."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "original_id": "can-1",
            "remaining_members": 2,
            "new_entities": [{"canonical_id": "new-1", "member_id": "m-1"}],
        }
        client = _make_client({"post": resp})
        args = _make_args(
            action="split", canonical_id="can-1", member_ids=["m-1"], reason=None,
        )
        _capture_stdout(cmd_entity, args, client)

        call_args = client.post.call_args
        path = call_args[0][0]
        body = call_args[0][1]
        assert path == "/v1/entities/split"
        assert body["canonical_id"] == "can-1"
        assert body["member_ids"] == ["m-1"]

    def test_split_multiple_members(self):
        """Split can eject multiple members at once."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "original_id": "can-1",
            "remaining_members": 1,
            "new_entities": [
                {"canonical_id": "new-1", "member_id": "m-1"},
                {"canonical_id": "new-2", "member_id": "m-2"},
            ],
        }
        client = _make_client({"post": resp})
        args = _make_args(
            action="split", canonical_id="can-1", member_ids=["m-1", "m-2"], reason=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "new-1" in output
        assert "new-2" in output
        assert "m-1" in output
        assert "m-2" in output

    def test_split_includes_reason(self):
        """Reason should be included in body when provided."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"original_id": "c", "remaining_members": 0, "new_entities": []}
        client = _make_client({"post": resp})
        args = _make_args(
            action="split", canonical_id="c", member_ids=["m1"], reason="Bad match",
        )
        _capture_stdout(cmd_entity, args, client)

        body = client.post.call_args[0][1]
        assert body["reason"] == "Bad match"

    def test_split_displays_remaining(self):
        """Output should show remaining members count."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"original_id": "can-1", "remaining_members": 5, "new_entities": []}
        client = _make_client({"post": resp})
        args = _make_args(
            action="split", canonical_id="can-1", member_ids=["m1"], reason=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "remaining members: 5" in output


class TestEntityReassign:
    """Tests for entity reassign - POST /v1/entities/reassign."""

    def test_reassign_sends_correct_body(self):
        """Reassign sends external_entity_id and to_canonical_id."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "external_entity_id": "ext-1",
            "from_canonical_id": "old-can",
            "to_canonical_id": "new-can",
        }
        client = _make_client({"post": resp})
        args = _make_args(
            action="reassign", ext_id="ext-1", to_entity="new-can", reason=None,
        )
        _capture_stdout(cmd_entity, args, client)

        call_args = client.post.call_args
        path = call_args[0][0]
        body = call_args[0][1]
        assert path == "/v1/entities/reassign"
        assert body["external_entity_id"] == "ext-1"
        assert body["to_canonical_id"] == "new-can"

    def test_reassign_displays_from_to(self):
        """Output should show from and to entity IDs."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "external_entity_id": "ext-1",
            "from_canonical_id": "from-aaa",
            "to_canonical_id": "to-bbb",
        }
        client = _make_client({"post": resp})
        args = _make_args(
            action="reassign", ext_id="ext-1", to_entity="to-bbb", reason=None,
        )
        output = _capture_stdout(cmd_entity, args, client)
        assert "from-aaa" in output
        assert "to-bbb" in output

    def test_reassign_includes_reason(self):
        """Reason should be included when provided."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"external_entity_id": "e", "from_canonical_id": "f", "to_canonical_id": "t"}
        client = _make_client({"post": resp})
        args = _make_args(
            action="reassign", ext_id="e", to_entity="t", reason="Wrong cluster",
        )
        _capture_stdout(cmd_entity, args, client)

        body = client.post.call_args[0][1]
        assert body["reason"] == "Wrong cluster"


class TestEntityDelete:
    """Tests for entity delete - DELETE /v1/entities/delete/:id."""

    def test_delete_calls_correct_path(self):
        """Delete should call DELETE /v1/entities/delete/{id}."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"deleted": True, "entity_id": "abc-123"}
        client = _make_client({"delete": resp})
        args = _make_args(action="delete", entity_id="abc-123")
        output = _capture_stdout(cmd_entity, args, client)

        client.delete.assert_called_once_with("/v1/entities/delete/abc-123")
        assert "Deleted entity abc-123" in output

    def test_delete_json_format(self):
        """JSON format should dump raw response."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"deleted": True, "entity_id": "abc"}
        client = _make_client({"delete": resp})
        args = _make_args(action="delete", entity_id="abc", format="json")
        output = _capture_stdout(cmd_entity, args, client)
        assert '"deleted"' in output


class TestEntityLink:
    """Tests for entity link - POST /v1/entities/link."""

    def test_link_sends_entity_ids(self):
        """Link sends entity_a_id and entity_b_id."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"edge_id": "edge-1", "entity_a_id": "a", "entity_b_id": "b", "weight": 1.0}
        client = _make_client({"post": resp})
        args = _make_args(
            action="link", entity_a="a", entity_b="b", weight=None,
        )
        _capture_stdout(cmd_entity, args, client)

        call_args = client.post.call_args
        path = call_args[0][0]
        body = call_args[0][1]
        assert path == "/v1/entities/link"
        assert body["entity_a_id"] == "a"
        assert body["entity_b_id"] == "b"
        assert "weight" not in body

    def test_link_includes_weight(self):
        """Weight should be included when provided."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"edge_id": "e", "entity_a_id": "a", "entity_b_id": "b", "weight": 0.5}
        client = _make_client({"post": resp})
        args = _make_args(
            action="link", entity_a="a", entity_b="b", weight=0.5,
        )
        _capture_stdout(cmd_entity, args, client)

        body = client.post.call_args[0][1]
        assert body["weight"] == 0.5

    def test_link_displays_edge_id(self):
        """Output should show the edge ID."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"edge_id": "edge-xyz", "entity_a_id": "a", "entity_b_id": "b", "weight": 1.0}
        client = _make_client({"post": resp})
        args = _make_args(action="link", entity_a="a", entity_b="b", weight=None)
        output = _capture_stdout(cmd_entity, args, client)
        assert "edge-xyz" in output


class TestEntityUnlink:
    """Tests for entity unlink - POST /v1/entities/unlink."""

    def test_unlink_sends_entity_ids(self):
        """Unlink sends entity_a_id and entity_b_id."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"unlinked": True, "entity_a_id": "a", "entity_b_id": "b"}
        client = _make_client({"post": resp})
        args = _make_args(action="unlink", entity_a="a", entity_b="b")
        _capture_stdout(cmd_entity, args, client)

        call_args = client.post.call_args
        path = call_args[0][0]
        body = call_args[0][1]
        assert path == "/v1/entities/unlink"
        assert body["entity_a_id"] == "a"
        assert body["entity_b_id"] == "b"

    def test_unlink_displays_confirmation(self):
        """Output should confirm the unlink."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {"unlinked": True, "entity_a_id": "aaa", "entity_b_id": "bbb"}
        client = _make_client({"post": resp})
        args = _make_args(action="unlink", entity_a="aaa", entity_b="bbb")
        output = _capture_stdout(cmd_entity, args, client)
        assert "Unlinked" in output
        assert "aaa" in output
        assert "bbb" in output


class TestEntityExplain:
    """Tests for entity explain - GET /v1/entities/:id/explain."""

    def test_explain_calls_correct_path(self):
        """Explain calls GET /v1/entities/{id}/explain."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc-123",
            "member_count": 3,
            "merge_history": [],
            "events": [],
            "match_evidence": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc-123")
        _capture_stdout(cmd_entity, args, client)

        client.get.assert_called_once_with("/v1/entities/abc-123/explain")

    def test_explain_displays_member_count(self):
        """Output should show entity ID and member count."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc-123",
            "member_count": 5,
            "merge_history": [],
            "events": [],
            "match_evidence": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc-123")
        output = _capture_stdout(cmd_entity, args, client)
        assert "abc-123" in output
        assert "5 members" in output

    def test_explain_shows_merge_history(self):
        """Merge history should be displayed with timestamps and types."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc",
            "member_count": 2,
            "merge_history": [{
                "id": "m1",
                "winner_entity_id": "winner-aaa-full-uuid",
                "loser_entity_id": "loser-bbb-full-uuid",
                "merge_type": "auto",
                "confidence": 0.95,
                "merged_at": "2026-02-25T10:00:00Z",
            }],
            "events": [],
            "match_evidence": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "Merge History" in output
        assert "auto" in output
        assert "2026-02-25" in output
        assert "winner-a" in output

    def test_explain_shows_events(self):
        """Events should be displayed with timestamps and types."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc",
            "member_count": 1,
            "merge_history": [],
            "events": [{
                "id": "ev1",
                "event_type": "entity.created",
                "payload": {},
                "created_at": "2026-02-20T08:00:00Z",
            }],
            "match_evidence": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "Events" in output
        assert "entity.created" in output
        assert "2026-02-20" in output

    def test_explain_shows_match_evidence(self):
        """Match evidence should show pair scores and decisions."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc",
            "member_count": 2,
            "merge_history": [],
            "events": [],
            "match_evidence": [{
                "entity_a_id": "aaaaaaaa-1111-2222-3333-444444444444",
                "entity_b_id": "bbbbbbbb-5555-6666-7777-888888888888",
                "confidence": 0.92,
                "decision": "merge",
                "rule_trace": {"email": 0.9},
            }],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "Match Evidence" in output
        assert "0.92" in output
        assert "merge" in output

    def test_explain_empty_shows_no_history(self):
        """Empty explain should show 'No history or evidence' message."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc",
            "member_count": 1,
            "merge_history": [],
            "events": [],
            "match_evidence": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc")
        output = _capture_stdout(cmd_entity, args, client)
        assert "No history or evidence" in output

    def test_explain_json_format(self):
        """JSON format should dump raw response."""
        from kanoniv.cli.commands.entity import cmd_entity

        resp = {
            "entity_id": "abc",
            "member_count": 1,
            "merge_history": [],
            "events": [],
            "match_evidence": [],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="explain", entity_id="abc", format="json")
        output = _capture_stdout(cmd_entity, args, client)
        assert '"entity_id"' in output


# ===========================================================================
# graph.py tests
# ===========================================================================

class TestGraphStats:
    """Tests for graph stats - GraphStats DTO."""

    def test_stats_uses_correct_field_names(self):
        """Must read nodes/edges/components/density/avg_degree."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "nodes": 1500,
            "edges": 3200,
            "components": 42,
            "density": 0.0028,
            "avg_degree": 4.27,
        }
        client = _make_client({"get": resp})
        args = _make_args(action="stats")
        output = _capture_stdout(cmd_graph, args, client)
        assert "1500" in output
        assert "3200" in output
        assert "42" in output
        # Should NOT contain old field names
        assert "total_entities" not in output.lower()


class TestGraphCluster:
    """Tests for graph cluster - ClusterDetail DTO."""

    def test_cluster_handles_uuid_list(self):
        """Members is Vec<Uuid> (list of strings), not list of dicts."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "component_index": 3,
            "members": [
                "aaaaaaaa-1111-2222-3333-444444444444",
                "bbbbbbbb-5555-6666-7777-888888888888",
            ],
            "internal_edges": 1,
            "density": 1.0,
        }
        client = _make_client({"get": resp})
        args = _make_args(action="cluster", entity_id="abc")
        output = _capture_stdout(cmd_graph, args, client)
        assert "aaaaaaaa..." in output
        assert "bbbbbbbb..." in output
        # Should NOT crash with AttributeError on .get()

    def test_cluster_empty_members(self):
        """Empty cluster shows message."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {"component_index": 0, "members": [], "internal_edges": 0, "density": 0.0}
        client = _make_client({"get": resp})
        args = _make_args(action="cluster", entity_id="abc")
        output = _capture_stdout(cmd_graph, args, client)
        assert "No cluster members" in output


class TestGraphBridges:
    """Tests for graph bridges - Vec<BridgeEntity> DTO."""

    def test_bridges_uses_correct_fields(self):
        """Must read entity_id/bridge_score/degree/components_connected."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = [
            {
                "entity_id": "aaaaaaaa-1111-2222-3333-444444444444",
                "bridge_score": 0.85,
                "degree": 12,
                "components_connected": 3,
            }
        ]
        client = _make_client({"get": resp})
        args = _make_args(action="bridges")
        output = _capture_stdout(cmd_graph, args, client)
        assert "0.85" in output
        assert "12" in output
        assert "3" in output
        assert "BRIDGE_SCORE" in output


class TestGraphInfluence:
    """Tests for graph influence - InfluenceScore DTO."""

    def test_influence_uses_correct_fields(self):
        """Must read pagerank/betweenness/degree/reach_2hop."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "entity_id": "abc",
            "pagerank": 0.042,
            "betweenness": 0.15,
            "degree": 8,
            "reach_2hop": 45,
        }
        client = _make_client({"get": resp})
        args = _make_args(action="influence", entity_id="abc")
        output = _capture_stdout(cmd_graph, args, client)
        assert "0.042" in output
        assert "0.15" in output
        assert "PageRank" in output
        assert "Betweenness" in output


class TestGraphRisk:
    """Tests for graph risk - RiskResponse DTO."""

    def test_risk_uses_correct_fields(self):
        """Must read churn_risk/contagion_risk/merge_risk/composite_risk."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "entity_id": "abc",
            "churn_risk": 0.3,
            "contagion_risk": 0.1,
            "merge_risk": 0.5,
            "composite_risk": 0.3,
        }
        client = _make_client({"get": resp})
        args = _make_args(action="risk", entity_id="abc")
        output = _capture_stdout(cmd_graph, args, client)
        assert "Churn Risk" in output
        assert "Composite Risk" in output
        assert "0.3" in output
        # Should NOT contain old field names
        assert "risk_factors" not in output.lower()


class TestGraphOrphans:
    """Tests for graph orphans - OrphansResponse DTO."""

    def test_orphans_parses_response(self):
        """Must read orphans array and total from OrphansResponse."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "orphans": [
                {"entity_id": "aaaaaaaa-1111-2222-3333-444444444444", "entity_type": None, "member_count": 1},
                {"entity_id": "bbbbbbbb-5555-6666-7777-888888888888", "entity_type": "person", "member_count": 1},
            ],
            "total": 2,
        }
        client = _make_client({"get": resp})
        args = _make_args(action="orphans", limit=20, entity_type=None)
        output = _capture_stdout(cmd_graph, args, client)
        assert "aaaaaaaa..." in output
        assert "person" in output
        assert "2 orphan" in output


class TestGraphConflicts:
    """Tests for graph conflicts - ConflictsResponse DTO."""

    def test_conflicts_handles_entities_involved_as_list(self):
        """entities_involved is Vec<Uuid>, display as count."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "conflicts": [{
                "cluster_id": "aaaaaaaa-1111-2222-3333-444444444444",
                "conflict_type": "mixed_edge_types",
                "severity": "medium",
                "entities_involved": [
                    "e1-uuid", "e2-uuid", "e3-uuid",
                ],
                "description": "Cluster has 2 identity edges and 1 relationship edge",
            }],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="conflicts", limit=20)
        output = _capture_stdout(cmd_graph, args, client)
        assert "3" in output  # count of entities_involved
        assert "mixed_edge_types" in output


class TestGraphDensity:
    """Tests for graph density - DensityResponse DTO."""

    def test_density_uses_correct_fields(self):
        """Must read median_degree (not clustering_coefficient or avg_path_length)."""
        from kanoniv.cli.commands.graph import cmd_graph

        resp = {
            "edge_density": 0.003,
            "avg_degree": 4.5,
            "max_degree": 42,
            "median_degree": 3.0,
            "connected_components": 15,
            "largest_component_size": 200,
        }
        client = _make_client({"get": resp})
        args = _make_args(action="density")
        output = _capture_stdout(cmd_graph, args, client)
        assert "Median Degree" in output
        assert "3.0" in output
        assert "200" in output
        # Old phantom fields should not appear
        assert "Clustering" not in output
        assert "Path Length" not in output


# ===========================================================================
# feedback.py tests
# ===========================================================================

class TestFeedbackList:
    """Tests for feedback list - Vec<FeedbackLabelResponse> DTO."""

    def test_list_shows_source_columns(self):
        """Must show source_a and source_b columns."""
        from kanoniv.cli.commands.feedback import cmd_feedback

        resp = [{
            "id": "aaaaaaaa-1111-2222-3333-444444444444",
            "entity_a_id": "rec-a-1",
            "entity_b_id": "rec-b-1",
            "source_a": "crm",
            "source_b": "erp",
            "label": "match",
            "reason": None,
            "created_at": "2026-02-25T10:00:00Z",
        }]
        client = _make_client({"get": resp})
        args = _make_args(action="list", limit=20)
        output = _capture_stdout(cmd_feedback, args, client)
        assert "crm" in output
        assert "erp" in output
        assert "SOURCE_A" in output
        assert "SOURCE_B" in output


class TestFeedbackCreate:
    """Tests for feedback create - CreateFeedbackRequest DTO."""

    def test_create_includes_source_fields(self):
        """Request must include source_a and source_b (required by API)."""
        from kanoniv.cli.commands.feedback import cmd_feedback

        client = _make_client({"post": [{"id": "fb-1"}]})
        args = _make_args(
            action="create", entity_a="e1", entity_b="e2",
            source_a="crm", source_b="erp", label="match", reason=None,
        )
        _capture_stdout(cmd_feedback, args, client)

        call_args = client.post.call_args
        body = call_args[0][1]
        label_input = body["labels"][0]
        assert label_input["source_a"] == "crm"
        assert label_input["source_b"] == "erp"
        assert label_input["entity_a_id"] == "e1"
        assert label_input["entity_b_id"] == "e2"
        assert label_input["label"] == "match"

    def test_create_includes_reason_when_provided(self):
        """Optional reason should be included."""
        from kanoniv.cli.commands.feedback import cmd_feedback

        client = _make_client({"post": [{"id": "fb-2"}]})
        args = _make_args(
            action="create", entity_a="e1", entity_b="e2",
            source_a="crm", source_b="erp", label="no_match",
            reason="Clearly different people",
        )
        _capture_stdout(cmd_feedback, args, client)

        body = client.post.call_args[0][1]
        assert body["labels"][0]["reason"] == "Clearly different people"


# ===========================================================================
# override.py tests
# ===========================================================================

class TestOverrideList:
    """Tests for override list - Vec<ManualOverride> DTO."""

    def test_list_extracts_entity_ids_from_override_data(self):
        """Entity IDs live inside override_data, not top-level."""
        from kanoniv.cli.commands.override import cmd_override

        resp = [{
            "id": "aaaaaaaa-1111-2222-3333-444444444444",
            "tenant_id": "t1",
            "canonical_entity_id": None,
            "override_type": "force_merge",
            "override_data": {
                "entity_a_id": "entity-aaa",
                "entity_b_id": "entity-bbb",
            },
            "reason": "Manual correction",
            "created_by": "user-1",
            "created_at": "2026-02-25T10:00:00Z",
            "superseded_at": None,
        }]
        client = _make_client({"get": resp})
        args = _make_args(action="list")
        output = _capture_stdout(cmd_override, args, client)
        assert "entity-aaa" in output
        assert "entity-bbb" in output
        assert "force_merge" in output
        assert "Manual correction" in output

    def test_list_handles_empty_override_data(self):
        """Should not crash if override_data is null."""
        from kanoniv.cli.commands.override import cmd_override

        resp = [{
            "id": "ov-1",
            "override_type": "force_split",
            "override_data": None,
            "reason": None,
            "created_at": "2026-01-01T00:00:00Z",
        }]
        client = _make_client({"get": resp})
        args = _make_args(action="list")
        output = _capture_stdout(cmd_override, args, client)
        assert "force_split" in output


class TestOverrideCreate:
    """Tests for override create - ManualOverride DTO."""

    def test_create_wraps_entity_ids_in_override_data(self):
        """Entity IDs must go inside override_data dict."""
        from kanoniv.cli.commands.override import cmd_override

        client = _make_client({"post": {"id": "ov-new"}})
        args = _make_args(
            action="create", override_type="merge",
            entity_a="e1", entity_b="e2",
        )
        _capture_stdout(cmd_override, args, client)

        body = client.post.call_args[0][1]
        assert body["override_type"] == "force_merge"
        assert "override_data" in body
        assert body["override_data"]["entity_a_id"] == "e1"


# ===========================================================================
# row.py tests
# ===========================================================================

class TestRowTrace:
    """Tests for row trace - TraceResponse DTO."""

    def test_trace_uses_entity_id_fields(self):
        """Must read entity_a_id/entity_b_id, not other_source/other_external_id."""
        from kanoniv.cli.commands.row import cmd_row

        resp = {
            "traces": [
                {
                    "entity_a_id": "aaaaaaaa-1111-2222-3333-444444444444",
                    "entity_b_id": "bbbbbbbb-5555-6666-7777-888888888888",
                    "score": 0.92,
                    "decision": "merge",
                    "rule_trace": {"rule1": 0.9},
                    "created_at": "2026-02-25T10:00:00Z",
                }
            ]
        }
        client = _make_client({"get": resp})
        args = _make_args(action="trace", source="crm", external_id="CRM-001")
        output = _capture_stdout(cmd_row, args, client)
        assert "aaaaaaaa..." in output
        assert "bbbbbbbb..." in output
        assert "0.92" in output
        assert "merge" in output
        assert "ENTITY_A" in output
        assert "ENTITY_B" in output

    def test_trace_empty(self):
        """No traces shows message."""
        from kanoniv.cli.commands.row import cmd_row

        resp = {"traces": []}
        client = _make_client({"get": resp})
        args = _make_args(action="trace", source="crm", external_id="X")
        output = _capture_stdout(cmd_row, args, client)
        assert "No match trace" in output


# ===========================================================================
# match.py tests
# ===========================================================================

class TestMatchCluster:
    """Tests for match cluster - ClusterResponse DTO."""

    def test_cluster_shows_members(self):
        """ClusterResponse has cluster_id, size, and members with source_name."""
        from kanoniv.cli.commands.match import cmd_match

        resp = {
            "cluster_id": "canonical-123",
            "size": 3,
            "members": [
                {
                    "entity_id": "ext-1",
                    "source_name": "crm",
                    "external_id": "CRM-001",
                    "confidence": 0.95,
                },
                {
                    "entity_id": "ext-2",
                    "source_name": "erp",
                    "external_id": "ERP-001",
                    "confidence": 0.88,
                },
            ],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="cluster", entity_id="ext-1")
        output = _capture_stdout(cmd_match, args, client)
        assert "crm" in output
        assert "CRM-001" in output
        assert "0.95" in output


class TestMatchCandidates:
    """Tests for match candidates - CandidatesResponse DTO."""

    def test_candidates_shows_entity_fields(self):
        """MergeCandidate has entity_id, entity_type, score, member_count."""
        from kanoniv.cli.commands.match import cmd_match

        resp = {
            "candidates": [
                {
                    "entity_id": "aaaaaaaa-1111-2222-3333-444444444444",
                    "entity_type": "person",
                    "score": 0.78,
                    "member_count": 5,
                },
            ],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="candidates", entity_id="abc", limit=20)
        output = _capture_stdout(cmd_match, args, client)
        assert "person" in output
        assert "0.78" in output
        assert "5" in output


# ===========================================================================
# source.py tests
# ===========================================================================

class TestSourceStats:
    """Tests for source stats - SourceStats DTO."""

    def test_stats_reads_last_ingested_at(self):
        """Must read last_ingested_at for the timestamp field."""
        from kanoniv.cli.commands.source import cmd_source

        resp = {
            "name": "crm",
            "record_count": 5000,
            "unique_ids": 4800,
            "duplicate_count": 200,
            "null_rate": 0.05,
            "last_ingested_at": "2026-02-25T14:00:00Z",
            "field_stats": [
                {"name": "email", "fill_rate": 0.95, "cardinality": 4750, "avg_length": 22},
            ],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="stats", source_id="s1")
        output = _capture_stdout(cmd_source, args, client)
        assert "5000" in output
        assert "2026-02-25" in output
        assert "email" in output
        assert "0.95" in output


class TestSourceQuality:
    """Tests for source quality - SourceQuality DTO."""

    def test_quality_shows_grade_and_issues(self):
        """Quality response has quality_score, grade, issues."""
        from kanoniv.cli.commands.source import cmd_source

        resp = {
            "name": "crm",
            "quality_score": 72,
            "grade": "B",
            "records_analyzed": 5000,
            "issues": [
                {"severity": "warning", "message": "Low fill rate on phone field"},
            ],
        }
        client = _make_client({"get": resp})
        args = _make_args(action="quality", source_id="s1")
        output = _capture_stdout(cmd_source, args, client)
        assert "72" in output
        assert "B" in output
        assert "Low fill rate" in output


# ===========================================================================
# output.py tests
# ===========================================================================

class TestOutputFormatting:
    """Tests for the shared output module."""

    def test_print_table_computes_column_widths(self):
        """Columns should expand to fit longest value."""
        output = _capture_stdout(
            __import__("kanoniv.cli.output", fromlist=["print_table"]).print_table,
            ["A", "B"],
            [["short", "longvalue123"]],
        )
        assert "longvalue123" in output
        assert "A" in output

    def test_print_table_empty_rows(self):
        """Empty rows should print (no results)."""
        from kanoniv.cli.output import print_table

        output = _capture_stdout(print_table, ["A", "B"], [])
        assert "(no results)" in output

    def test_print_detail_formats_labels(self):
        """Detail view should right-pad labels."""
        from kanoniv.cli.output import print_detail

        output = _capture_stdout(
            print_detail, "Test", [("Name", "Alice"), ("Score", "95")]
        )
        assert "Test" in output
        assert "Alice" in output
        assert "95" in output


# ===========================================================================
# Argument parser tests
# ===========================================================================

class TestParserConstruction:
    """Test that the CLI parser builds without errors and required args are present."""

    def test_parser_builds(self):
        """Parser construction should not raise."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        assert parser is not None

    def test_feedback_create_requires_sources(self):
        """feedback create must accept --source-a and --source-b."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        # Should parse successfully with all required args
        args = parser.parse_args([
            "feedback", "create", "entity-a", "entity-b",
            "--source-a", "crm", "--source-b", "erp",
            "--label", "match",
        ])
        assert args.source_a == "crm"
        assert args.source_b == "erp"
        assert args.label == "match"

    def test_feedback_create_requires_label_choice(self):
        """feedback create --label must be match or no_match."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        with pytest.raises(SystemExit):
            parser.parse_args([
                "feedback", "create", "e1", "e2",
                "--source-a", "crm", "--source-b", "erp",
                "--label", "invalid_label",
            ])

    def test_entity_subcommands(self):
        """entity domain should accept all subcommands including new graph mutation verbs."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        for action in ["show", "search", "linked", "history", "merge", "split",
                        "reassign", "delete", "link", "unlink", "explain",
                        "lock", "revert", "attrs", "candidates", "diff"]:
            # Just verify parsing doesn't crash (some need positional args)
            if action == "show":
                args = parser.parse_args(["entity", action, "some-id"])
                assert args.action == action

    def test_entity_merge_parser(self):
        """entity merge accepts entity_a, entity_b, and optional --reason."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "merge", "aaa", "bbb", "--reason", "dup"])
        assert args.action == "merge"
        assert args.entity_a == "aaa"
        assert args.entity_b == "bbb"
        assert args.reason == "dup"

    def test_entity_split_parser(self):
        """entity split accepts canonical_id and one or more member_ids."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "split", "can-1", "m1", "m2", "m3"])
        assert args.action == "split"
        assert args.canonical_id == "can-1"
        assert args.member_ids == ["m1", "m2", "m3"]

    def test_entity_split_parser_with_reason(self):
        """entity split accepts optional --reason."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args([
            "entity", "split", "can-1", "m1", "--reason", "Incoherent cluster",
        ])
        assert args.reason == "Incoherent cluster"

    def test_entity_reassign_parser(self):
        """entity reassign accepts ext_id and to_entity."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args([
            "entity", "reassign", "ext-abc", "target-xyz", "--reason", "Wrong match",
        ])
        assert args.action == "reassign"
        assert args.ext_id == "ext-abc"
        assert args.to_entity == "target-xyz"
        assert args.reason == "Wrong match"

    def test_entity_delete_parser(self):
        """entity delete accepts entity_id."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "delete", "abc-123"])
        assert args.action == "delete"
        assert args.entity_id == "abc-123"

    def test_entity_link_parser(self):
        """entity link accepts entity_a, entity_b, and optional --weight."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "link", "a1", "b2", "--weight", "0.75"])
        assert args.action == "link"
        assert args.entity_a == "a1"
        assert args.entity_b == "b2"
        assert args.weight == 0.75

    def test_entity_link_parser_no_weight(self):
        """entity link weight defaults to None when not provided."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "link", "a1", "b2"])
        assert args.weight is None

    def test_entity_unlink_parser(self):
        """entity unlink accepts entity_a and entity_b."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "unlink", "a1", "b2"])
        assert args.action == "unlink"
        assert args.entity_a == "a1"
        assert args.entity_b == "b2"

    def test_entity_explain_parser(self):
        """entity explain accepts entity_id."""
        from kanoniv.cli.main import _build_parser

        parser = _build_parser()
        args = parser.parse_args(["entity", "explain", "abc-123"])
        assert args.action == "explain"
        assert args.entity_id == "abc-123"
