import json
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch

import amc_server.mixins.state as state_mod
from amc_server.mixins.state import StateMixin
from amc_server.mixins.parsing import SessionParsingMixin
from amc_server.mixins.discovery import SessionDiscoveryMixin


class DummyStateHandler(StateMixin, SessionParsingMixin, SessionDiscoveryMixin):
    pass


class TestGetActiveZellijSessions(unittest.TestCase):
    """Tests for _get_active_zellij_sessions edge cases."""

    def setUp(self):
        state_mod._zellij_cache["sessions"] = None
        state_mod._zellij_cache["expires"] = 0

    def test_parses_output_with_metadata(self):
        handler = DummyStateHandler()
        completed = subprocess.CompletedProcess(
            args=[],
            returncode=0,
            stdout="infra [created 1h ago]\nwork\n",
            stderr="",
        )

        with patch.object(state_mod, "ZELLIJ_BIN", "/opt/homebrew/bin/zellij"), \
             patch("amc_server.mixins.state.subprocess.run", return_value=completed) as run_mock:
            sessions = handler._get_active_zellij_sessions()

        self.assertEqual(sessions, {"infra", "work"})
        args = run_mock.call_args.args[0]
        self.assertEqual(args, ["/opt/homebrew/bin/zellij", "list-sessions", "--no-formatting"])

    def test_empty_output_returns_empty_set(self):
        handler = DummyStateHandler()
        completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")

        with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
            sessions = handler._get_active_zellij_sessions()

        self.assertEqual(sessions, set())

    def test_whitespace_only_lines_ignored(self):
        handler = DummyStateHandler()
        completed = subprocess.CompletedProcess(
            args=[], returncode=0, stdout="session1\n   \n\nsession2\n", stderr=""
        )

        with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
            sessions = handler._get_active_zellij_sessions()

        self.assertEqual(sessions, {"session1", "session2"})

    def test_nonzero_exit_returns_none(self):
        handler = DummyStateHandler()
        completed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error")

        with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
            sessions = handler._get_active_zellij_sessions()

        self.assertIsNone(sessions)

    def test_timeout_returns_none(self):
        handler = DummyStateHandler()

        with patch("amc_server.mixins.state.subprocess.run",
                   side_effect=subprocess.TimeoutExpired("cmd", 2)):
            sessions = handler._get_active_zellij_sessions()

        self.assertIsNone(sessions)

    def test_file_not_found_returns_none(self):
        handler = DummyStateHandler()

        with patch("amc_server.mixins.state.subprocess.run", side_effect=FileNotFoundError()):
            sessions = handler._get_active_zellij_sessions()

        self.assertIsNone(sessions)

    def test_cache_used_when_fresh(self):
        handler = DummyStateHandler()
        state_mod._zellij_cache["sessions"] = {"cached"}
        state_mod._zellij_cache["expires"] = time.time() + 100

        with patch("amc_server.mixins.state.subprocess.run") as mock_run:
            sessions = handler._get_active_zellij_sessions()

        mock_run.assert_not_called()
        self.assertEqual(sessions, {"cached"})


class TestIsSessionDead(unittest.TestCase):
    """Tests for _is_session_dead edge cases."""

    def setUp(self):
        self.handler = DummyStateHandler()

    def test_starting_session_not_dead(self):
        session = {"status": "starting", "agent": "claude", "zellij_session": "s"}
        self.assertFalse(self.handler._is_session_dead(session, {"s"}, set()))

    def test_claude_without_zellij_session_is_dead(self):
        session = {"status": "active", "agent": "claude", "zellij_session": ""}
        self.assertTrue(self.handler._is_session_dead(session, set(), set()))

    def test_claude_with_missing_zellij_session_is_dead(self):
        session = {"status": "active", "agent": "claude", "zellij_session": "deleted"}
        active_zellij = {"other_session"}
        self.assertTrue(self.handler._is_session_dead(session, active_zellij, set()))

    def test_claude_with_active_zellij_session_not_dead(self):
        session = {"status": "active", "agent": "claude", "zellij_session": "existing"}
        active_zellij = {"existing", "other"}
        self.assertFalse(self.handler._is_session_dead(session, active_zellij, set()))

    def test_claude_unknown_zellij_status_assumes_alive(self):
        # When we can't query zellij (None), assume alive to avoid false positives
        session = {"status": "active", "agent": "claude", "zellij_session": "unknown"}
        self.assertFalse(self.handler._is_session_dead(session, None, set()))

    def test_codex_without_transcript_path_is_dead(self):
        session = {"status": "active", "agent": "codex", "transcript_path": ""}
        self.assertTrue(self.handler._is_session_dead(session, None, set()))

    def test_codex_with_active_transcript_not_dead(self):
        session = {"status": "active", "agent": "codex", "transcript_path": "/path/to/file.jsonl"}
        active_files = {"/path/to/file.jsonl"}
        self.assertFalse(self.handler._is_session_dead(session, None, active_files))

    def test_codex_without_active_transcript_checks_lsof(self):
        session = {"status": "active", "agent": "codex", "transcript_path": "/path/to/file.jsonl"}

        # Simulate lsof finding the file open
        with patch.object(self.handler, "_is_file_open", return_value=True):
            result = self.handler._is_session_dead(session, None, set())
        self.assertFalse(result)

        # Simulate lsof not finding the file
        with patch.object(self.handler, "_is_file_open", return_value=False):
            result = self.handler._is_session_dead(session, None, set())
        self.assertTrue(result)

    def test_unknown_agent_assumes_alive(self):
        session = {"status": "active", "agent": "unknown_agent"}
        self.assertFalse(self.handler._is_session_dead(session, None, set()))


class TestIsFileOpen(unittest.TestCase):
    """Tests for _is_file_open edge cases."""

    def setUp(self):
        self.handler = DummyStateHandler()

    def test_lsof_finds_pid_returns_true(self):
        completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="12345\n", stderr="")

        with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
            result = self.handler._is_file_open("/some/file.jsonl")

        self.assertTrue(result)

    def test_lsof_no_result_returns_false(self):
        completed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")

        with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
            result = self.handler._is_file_open("/some/file.jsonl")

        self.assertFalse(result)

    def test_lsof_timeout_returns_false(self):
        with patch("amc_server.mixins.state.subprocess.run",
                   side_effect=subprocess.TimeoutExpired("cmd", 2)):
            result = self.handler._is_file_open("/some/file.jsonl")

        self.assertFalse(result)

    def test_lsof_not_found_returns_false(self):
        with patch("amc_server.mixins.state.subprocess.run", side_effect=FileNotFoundError()):
            result = self.handler._is_file_open("/some/file.jsonl")

        self.assertFalse(result)


class TestCleanupStale(unittest.TestCase):
    """Tests for _cleanup_stale edge cases."""

    def setUp(self):
        self.handler = DummyStateHandler()

    def test_removes_orphan_event_logs_older_than_24h(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()

            # Create an orphan event log (no matching session)
            orphan_log = events_dir / "orphan.jsonl"
            orphan_log.write_text('{"event": "test"}\n')
            # Set mtime to 25 hours ago
            old_time = time.time() - (25 * 3600)
            import os
            os.utime(orphan_log, (old_time, old_time))

            with patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
                self.handler._cleanup_stale([])  # No active sessions

            self.assertFalse(orphan_log.exists())

    def test_keeps_orphan_event_logs_younger_than_24h(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()

            # Create a recent orphan event log
            recent_log = events_dir / "recent.jsonl"
            recent_log.write_text('{"event": "test"}\n')

            with patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
                self.handler._cleanup_stale([])

            self.assertTrue(recent_log.exists())

    def test_keeps_event_logs_with_active_session(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()

            # Create an old event log that HAS an active session
            event_log = events_dir / "active-session.jsonl"
            event_log.write_text('{"event": "test"}\n')
            old_time = time.time() - (25 * 3600)
            import os
            os.utime(event_log, (old_time, old_time))

            sessions = [{"session_id": "active-session"}]

            with patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
                self.handler._cleanup_stale(sessions)

            self.assertTrue(event_log.exists())

    def test_removes_stale_starting_sessions(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()

            # Create a stale "starting" session
            stale_session = sessions_dir / "stale.json"
            stale_session.write_text(json.dumps({"status": "starting"}))
            # Set mtime to 2 hours ago (> 1 hour threshold)
            old_time = time.time() - (2 * 3600)
            import os
            os.utime(stale_session, (old_time, old_time))

            with patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
                self.handler._cleanup_stale([])

            self.assertFalse(stale_session.exists())

    def test_keeps_stale_active_sessions(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()

            # Create an old "active" session (should NOT be deleted)
            active_session = sessions_dir / "active.json"
            active_session.write_text(json.dumps({"status": "active"}))
            old_time = time.time() - (2 * 3600)
            import os
            os.utime(active_session, (old_time, old_time))

            with patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
                self.handler._cleanup_stale([])

            self.assertTrue(active_session.exists())


class TestCollectSessions(unittest.TestCase):
    """Tests for _collect_sessions edge cases."""

    def setUp(self):
        self.handler = DummyStateHandler()

    def test_invalid_json_session_file_skipped(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()

            # Create an invalid JSON file
            bad_file = sessions_dir / "bad.json"
            bad_file.write_text("not json")

            # Create a valid session
            good_file = sessions_dir / "good.json"
            good_file.write_text(json.dumps({
                "session_id": "good",
                "agent": "claude",
                "status": "active",
                "last_event_at": "2024-01-01T00:00:00Z",
            }))

            with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
                 patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(self.handler, "_discover_active_codex_sessions"), \
                 patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
                 patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
                 patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
                 patch.object(self.handler, "_get_conversation_mtime", return_value=None):
                sessions = self.handler._collect_sessions()

            # Should only get the good session
            self.assertEqual(len(sessions), 1)
            self.assertEqual(sessions[0]["session_id"], "good")

    def test_non_dict_session_file_skipped(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()

            # Create a file with array instead of dict
            array_file = sessions_dir / "array.json"
            array_file.write_text("[1, 2, 3]")

            with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
                 patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(self.handler, "_discover_active_codex_sessions"), \
                 patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
                 patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
                 patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
                 patch.object(self.handler, "_get_conversation_mtime", return_value=None):
                sessions = self.handler._collect_sessions()

            self.assertEqual(len(sessions), 0)

    def test_orphan_starting_session_deleted(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()

            # Create a starting session with a non-existent zellij session
            orphan_file = sessions_dir / "orphan.json"
            orphan_file.write_text(json.dumps({
                "session_id": "orphan",
                "status": "starting",
                "zellij_session": "deleted_session",
            }))

            active_zellij = {"other_session"}  # orphan's session not in here

            with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
                 patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(self.handler, "_discover_active_codex_sessions"), \
                 patch.object(self.handler, "_get_active_zellij_sessions", return_value=active_zellij), \
                 patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
                 patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
                 patch.object(self.handler, "_get_conversation_mtime", return_value=None):
                sessions = self.handler._collect_sessions()

            # Orphan should be deleted and not in results
            self.assertEqual(len(sessions), 0)
            self.assertFalse(orphan_file.exists())

    def test_sessions_sorted_by_id(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            sessions_dir = Path(tmpdir) / "sessions"
            sessions_dir.mkdir()
            events_dir = Path(tmpdir) / "events"
            events_dir.mkdir()

            for sid in ["zebra", "alpha", "middle"]:
                (sessions_dir / f"{sid}.json").write_text(json.dumps({
                    "session_id": sid,
                    "status": "active",
                    "last_event_at": "2024-01-01T00:00:00Z",
                }))

            with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
                 patch.object(state_mod, "EVENTS_DIR", events_dir), \
                 patch.object(self.handler, "_discover_active_codex_sessions"), \
                 patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
                 patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
                 patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
                 patch.object(self.handler, "_get_conversation_mtime", return_value=None):
                sessions = self.handler._collect_sessions()

            ids = [s["session_id"] for s in sessions]
            self.assertEqual(ids, ["alpha", "middle", "zebra"])


if __name__ == "__main__":
    unittest.main()
