"""Tests for CLI commands: socket timeout, delete, output modes, proj_tag, ellipsis.

Uses mocks to avoid needing a running daemon for unit tests.
"""

from __future__ import annotations

import argparse
import json
import socket
from unittest.mock import MagicMock, patch

import pytest

from claude_engram.cli import (
    cmd_delete,
    cmd_recent,
    cmd_search,
    cmd_status,
    cmd_store,
    send_command,
)

# ---------------------------------------------------------------------------
# D1: Socket timeout tests
# ---------------------------------------------------------------------------


class TestSendCommandTimeout:
    """Tests for socket timeout handling in send_command."""

    @patch("claude_engram.cli.SOCKET_PATH")
    def test_send_command_sets_timeout(self, mock_path: MagicMock) -> None:
        """send_command should set a timeout on the socket."""
        mock_path.exists.return_value = True

        mock_sock = MagicMock(spec=socket.socket)
        mock_sock.recv.side_effect = [
            json.dumps({"status": "ok"}).encode() + b"\n",
            b"",
        ]

        with patch("claude_engram.cli.socket.socket", return_value=mock_sock):
            send_command({"action": "ping"})

        mock_sock.settimeout.assert_called_once_with(10)

    @patch("claude_engram.cli.SOCKET_PATH")
    def test_send_command_timeout_prints_error(
        self, mock_path: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """send_command should handle socket.timeout gracefully."""
        mock_path.exists.return_value = True

        mock_sock = MagicMock(spec=socket.socket)
        mock_sock.recv.side_effect = TimeoutError("timed out")

        with (
            patch("claude_engram.cli.socket.socket", return_value=mock_sock),
            pytest.raises(SystemExit) as exc_info,
        ):
            send_command({"action": "ping"})

        assert exc_info.value.code == 1
        captured = capsys.readouterr()
        assert "timed out" in captured.err.lower() or "timeout" in captured.err.lower()

    @patch("claude_engram.cli.SOCKET_PATH")
    def test_send_command_normal_operation_with_timeout(self, mock_path: MagicMock) -> None:
        """Normal operation should still work with timeout set."""
        mock_path.exists.return_value = True

        expected = {"status": "pong", "version": "0.2.0"}
        mock_sock = MagicMock(spec=socket.socket)
        mock_sock.recv.side_effect = [
            json.dumps(expected).encode() + b"\n",
            b"",
        ]

        with patch("claude_engram.cli.socket.socket", return_value=mock_sock):
            result = send_command({"action": "ping"})

        assert result == expected


# ---------------------------------------------------------------------------
# D4: CLI delete command tests
# ---------------------------------------------------------------------------


class TestCmdDelete:
    """Tests for the delete CLI command."""

    @patch("claude_engram.cli.send_command")
    def test_delete_success(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Successful delete should print confirmation."""
        mock_send.return_value = {"deleted": True}

        args = argparse.Namespace(id="42")
        cmd_delete(args)

        captured = capsys.readouterr()
        assert "42" in captured.out
        assert "deleted" in captured.out.lower() or "Deleted" in captured.out

    @patch("claude_engram.cli.send_command")
    def test_delete_not_found(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Delete of non-existent memory should print not-found message."""
        mock_send.return_value = {"deleted": False}

        args = argparse.Namespace(id="999")
        cmd_delete(args)

        captured = capsys.readouterr()
        assert "not found" in captured.out.lower()


# ---------------------------------------------------------------------------
# D2: CLI --full and --json output mode tests
# ---------------------------------------------------------------------------


class TestCmdSearchOutputModes:
    """Tests for search command output modes."""

    @patch("claude_engram.cli.send_command")
    def test_search_default_truncates(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Default search output should truncate content to 100 chars."""
        long_content = "x" * 200
        mock_send.return_value = {
            "results": [{"category": "lesson", "content": long_content, "similarity": 0.95}]
        }

        args = argparse.Namespace(query="test", limit=5, full=False, json_output=False)
        cmd_search(args)

        captured = capsys.readouterr()
        # Should NOT contain the full 200-char string
        assert long_content not in captured.out
        # Should contain truncated version
        assert "x" * 100 in captured.out

    @patch("claude_engram.cli.send_command")
    def test_search_full_shows_complete(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """--full flag should show complete content."""
        long_content = "x" * 200
        mock_send.return_value = {
            "results": [{"category": "lesson", "content": long_content, "similarity": 0.95}]
        }

        args = argparse.Namespace(query="test", limit=5, full=True, json_output=False)
        cmd_search(args)

        captured = capsys.readouterr()
        assert long_content in captured.out

    @patch("claude_engram.cli.send_command")
    def test_search_json_outputs_valid_json(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """--json flag should output valid JSON."""
        expected = {
            "results": [{"category": "lesson", "content": "test content", "similarity": 0.9}]
        }
        mock_send.return_value = expected

        args = argparse.Namespace(query="test", limit=5, full=False, json_output=True)
        cmd_search(args)

        captured = capsys.readouterr()
        parsed = json.loads(captured.out)
        assert parsed == expected


class TestCmdRecentOutputModes:
    """Tests for recent command output modes."""

    @patch("claude_engram.cli.send_command")
    def test_recent_default_truncates(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Default recent output should truncate content to 100 chars."""
        long_content = "y" * 200
        mock_send.return_value = {"results": [{"category": "decision", "content": long_content}]}

        args = argparse.Namespace(limit=10, full=False, json_output=False)
        cmd_recent(args)

        captured = capsys.readouterr()
        assert long_content not in captured.out
        assert "y" * 100 in captured.out

    @patch("claude_engram.cli.send_command")
    def test_recent_full_shows_complete(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """--full flag should show complete content."""
        long_content = "y" * 200
        mock_send.return_value = {"results": [{"category": "decision", "content": long_content}]}

        args = argparse.Namespace(limit=10, full=True, json_output=False)
        cmd_recent(args)

        captured = capsys.readouterr()
        assert long_content in captured.out

    @patch("claude_engram.cli.send_command")
    def test_recent_json_outputs_valid_json(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """--json flag should output valid JSON."""
        expected = {"results": [{"category": "decision", "content": "recent data"}]}
        mock_send.return_value = expected

        args = argparse.Namespace(limit=10, full=False, json_output=True)
        cmd_recent(args)

        captured = capsys.readouterr()
        parsed = json.loads(captured.out)
        assert parsed == expected


# ---------------------------------------------------------------------------
# Q1: Version string single source of truth
# ---------------------------------------------------------------------------


class TestVersionString:
    """Tests for version consistency."""

    def test_version_is_valid(self) -> None:
        """__version__ should be a valid version string from importlib.metadata."""
        from claude_engram import __version__

        # Version comes from importlib.metadata or falls back to "0.0.0-dev"
        assert isinstance(__version__, str)
        assert len(__version__) > 0
        # Must start with a digit (real version) or be the dev fallback
        assert __version__[0].isdigit() or __version__ == "0.0.0-dev"


# ---------------------------------------------------------------------------
# Q2: Ellipsis bug in CLI search/recent output
# ---------------------------------------------------------------------------


class TestEllipsisBug:
    """Tests for ellipsis only appearing when content is actually truncated."""

    @patch("claude_engram.cli.send_command")
    def test_search_short_content_no_ellipsis(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Short content should NOT have ellipsis appended."""
        short_content = "short text"
        mock_send.return_value = {
            "results": [{"category": "lesson", "content": short_content, "similarity": 0.95}]
        }
        args = argparse.Namespace(query="test", limit=5, full=False, json_output=False)
        cmd_search(args)
        captured = capsys.readouterr()
        assert short_content in captured.out
        assert "..." not in captured.out

    @patch("claude_engram.cli.send_command")
    def test_search_long_content_has_ellipsis(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Long content (>100 chars) should have ellipsis appended."""
        long_content = "a" * 200
        mock_send.return_value = {
            "results": [{"category": "lesson", "content": long_content, "similarity": 0.95}]
        }
        args = argparse.Namespace(query="test", limit=5, full=False, json_output=False)
        cmd_search(args)
        captured = capsys.readouterr()
        assert "..." in captured.out

    @patch("claude_engram.cli.send_command")
    def test_search_exactly_100_chars_no_ellipsis(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Content exactly 100 chars should NOT have ellipsis."""
        exact_content = "b" * 100
        mock_send.return_value = {
            "results": [{"category": "lesson", "content": exact_content, "similarity": 0.95}]
        }
        args = argparse.Namespace(query="test", limit=5, full=False, json_output=False)
        cmd_search(args)
        captured = capsys.readouterr()
        assert exact_content in captured.out
        assert "..." not in captured.out

    @patch("claude_engram.cli.send_command")
    def test_search_full_flag_no_ellipsis(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """With --full flag, no ellipsis regardless of content length."""
        long_content = "c" * 200
        mock_send.return_value = {
            "results": [{"category": "lesson", "content": long_content, "similarity": 0.95}]
        }
        args = argparse.Namespace(query="test", limit=5, full=True, json_output=False)
        cmd_search(args)
        captured = capsys.readouterr()
        assert long_content in captured.out
        assert "..." not in captured.out

    @patch("claude_engram.cli.send_command")
    def test_recent_short_content_no_ellipsis(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Short recent content should NOT have ellipsis."""
        short_content = "brief note"
        mock_send.return_value = {"results": [{"category": "decision", "content": short_content}]}
        args = argparse.Namespace(limit=10, full=False, json_output=False)
        cmd_recent(args)
        captured = capsys.readouterr()
        assert short_content in captured.out
        assert "..." not in captured.out

    @patch("claude_engram.cli.send_command")
    def test_recent_long_content_has_ellipsis(
        self,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Long recent content should have ellipsis."""
        long_content = "d" * 200
        mock_send.return_value = {"results": [{"category": "decision", "content": long_content}]}
        args = argparse.Namespace(limit=10, full=False, json_output=False)
        cmd_recent(args)
        captured = capsys.readouterr()
        assert "..." in captured.out


# ---------------------------------------------------------------------------
# Q8: CLI --proj-tag flag for store/search/recent
# ---------------------------------------------------------------------------


class TestProjTagFlag:
    """Tests for --proj-tag / -p CLI argument."""

    @patch("claude_engram.cli.send_command")
    def test_store_with_proj_tag(self, mock_send: MagicMock) -> None:
        """cmd_store with proj_tag should include it."""
        mock_send.return_value = {"id": 1}
        args = argparse.Namespace(content="t", category="lesson", proj_tag="mp")
        cmd_store(args)
        assert mock_send.call_args[0][0]["proj_tag"] == "mp"

    @patch("claude_engram.cli.send_command")
    def test_store_without_proj_tag(self, mock_send: MagicMock) -> None:
        """cmd_store without proj_tag should omit it."""
        mock_send.return_value = {"id": 1}
        args = argparse.Namespace(content="t", category="lesson", proj_tag=None)
        cmd_store(args)
        assert "proj_tag" not in mock_send.call_args[0][0]

    @patch("claude_engram.cli.send_command")
    def test_search_with_proj_tag(self, mock_send: MagicMock) -> None:
        """cmd_search with proj_tag should include it."""
        mock_send.return_value = {"results": []}
        ns = argparse.Namespace(query="q", limit=5, full=False, json_output=False, proj_tag="mp")
        cmd_search(ns)
        assert mock_send.call_args[0][0]["proj_tag"] == "mp"

    @patch("claude_engram.cli.send_command")
    def test_search_without_proj_tag(self, mock_send: MagicMock) -> None:
        """cmd_search without proj_tag should omit it."""
        mock_send.return_value = {"results": []}
        ns = argparse.Namespace(query="q", limit=5, full=False, json_output=False, proj_tag=None)
        cmd_search(ns)
        assert "proj_tag" not in mock_send.call_args[0][0]

    @patch("claude_engram.cli.send_command")
    def test_recent_with(self, mock_send: MagicMock) -> None:
        """cmd_recent with proj_tag."""
        mock_send.return_value = {"results": []}
        ns = argparse.Namespace(limit=10, full=False, json_output=False, proj_tag="mp")
        cmd_recent(ns)
        assert mock_send.call_args[0][0]["proj_tag"] == "mp"

    @patch("claude_engram.cli.send_command")
    def test_recent_without(self, mock_send: MagicMock) -> None:
        """cmd_recent without proj_tag."""
        mock_send.return_value = {"results": []}
        ns = argparse.Namespace(limit=10, full=False, json_output=False, proj_tag=None)
        cmd_recent(ns)
        assert "proj_tag" not in mock_send.call_args[0][0]

    def test_ap_store(self):
        import sys as _s

        from claude_engram.cli import main

        with (
            patch.object(_s, "argv", ["x", "store", "h", "-p", "t"]),
            patch("claude_engram.cli.send_command", return_value={"id": 1}),
        ):
            main()

    def test_ap_search(self):
        import sys as _s

        from claude_engram.cli import main

        with (
            patch.object(_s, "argv", ["x", "search", "q", "--proj-tag", "t"]),
            patch("claude_engram.cli.send_command", return_value={"results": []}),
        ):
            main()

    def test_ap_recent(self):
        import sys as _s

        from claude_engram.cli import main

        with (
            patch.object(_s, "argv", ["x", "recent", "-p", "t"]),
            patch("claude_engram.cli.send_command", return_value={"results": []}),
        ):
            main()


# ---------------------------------------------------------------------------
# PID mismatch: cmd_status should use daemon's actual PID from health_check
# ---------------------------------------------------------------------------


class TestCmdStatusPid:
    """Tests for cmd_status using actual daemon PID instead of stale PID file."""

    @patch("claude_engram.cli.send_command")
    @patch("claude_engram.daemon.is_running")
    def test_status_uses_actual_pid_from_health_check(
        self,
        mock_is_running: MagicMock,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """cmd_status should display the daemon's actual PID from health_check, not the file PID."""
        stale_pid = 1111
        actual_pid = 2222
        mock_is_running.return_value = stale_pid
        mock_send.side_effect = [
            {"status": "pong", "version": "0.3.0"},
            {"status": "ok", "health": {"pid": actual_pid}},
        ]

        args = argparse.Namespace()
        cmd_status(args)

        captured = capsys.readouterr()
        assert str(actual_pid) in captured.out
        assert str(stale_pid) not in captured.out

    @patch("claude_engram.cli.send_command")
    @patch("claude_engram.daemon.is_running")
    def test_status_falls_back_to_file_pid_on_health_error(
        self,
        mock_is_running: MagicMock,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """cmd_status should fall back to file PID if health_check returns error."""
        file_pid = 3333
        mock_is_running.return_value = file_pid
        mock_send.side_effect = [
            {"status": "pong", "version": "0.3.0"},
            {"status": "error", "error": "Health check timed out"},
        ]

        args = argparse.Namespace()
        cmd_status(args)

        captured = capsys.readouterr()
        assert str(file_pid) in captured.out

    @patch("claude_engram.cli.send_command")
    @patch("claude_engram.daemon.is_running")
    def test_status_falls_back_to_file_pid_on_exception(
        self,
        mock_is_running: MagicMock,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """cmd_status should fall back to file PID if health_check raises an exception."""
        file_pid = 4444
        mock_is_running.return_value = file_pid
        mock_send.side_effect = [
            {"status": "pong", "version": "0.3.0"},
            Exception("connection refused"),
        ]

        args = argparse.Namespace()
        cmd_status(args)

        captured = capsys.readouterr()
        assert str(file_pid) in captured.out

    @patch("claude_engram.cli.send_command")
    @patch("claude_engram.daemon.is_running")
    def test_status_falls_back_to_file_pid_on_system_exit(
        self,
        mock_is_running: MagicMock,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """cmd_status should fall back to file PID if health_check exits (socket gone)."""
        file_pid = 5555
        mock_is_running.return_value = file_pid
        mock_send.side_effect = [
            {"status": "pong", "version": "0.3.0"},
            SystemExit(1),
        ]

        args = argparse.Namespace()
        cmd_status(args)

        captured = capsys.readouterr()
        assert str(file_pid) in captured.out

    @patch("claude_engram.cli.send_command")
    @patch("claude_engram.daemon.is_running")
    def test_status_not_running(
        self,
        mock_is_running: MagicMock,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """cmd_status should print 'Not running' when daemon is not running."""
        mock_is_running.return_value = None

        args = argparse.Namespace()
        cmd_status(args)

        captured = capsys.readouterr()
        assert "Not running" in captured.out
        mock_send.assert_not_called()

    @patch("claude_engram.cli.send_command")
    @patch("claude_engram.daemon.is_running")
    def test_status_displays_version(
        self,
        mock_is_running: MagicMock,
        mock_send: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """cmd_status should display the version from ping response."""
        mock_is_running.return_value = 6666
        mock_send.side_effect = [
            {"status": "pong", "version": "1.2.3"},
            {"status": "ok", "health": {"pid": 6666}},
        ]

        args = argparse.Namespace()
        cmd_status(args)

        captured = capsys.readouterr()
        assert "1.2.3" in captured.out
