"""Tests for RecoveryService application service."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

from noteflow.application.services.recovery_service import (
    AudioValidationResult,
    RecoveryService,
)
from noteflow.domain.entities import Meeting
from noteflow.domain.value_objects import MeetingState


class TestRecoveryServiceRecovery:
    """Tests for crash recovery operations."""

    async def test_recover_no_crashed_meetings(self, mock_uow: MagicMock) -> None:
        """Test recovery with no crashed meetings."""
        mock_uow.meetings.list_all = AsyncMock(return_value=([], 0))

        service = RecoveryService(mock_uow)
        meetings, audio_failures = await service.recover_crashed_meetings()

        assert meetings == []
        assert audio_failures == 0
        mock_uow.commit.assert_not_called()

    async def test_recover_single_recording_meeting(self, mock_uow: MagicMock) -> None:
        """Test recovery of a meeting left in RECORDING state."""
        meeting = Meeting.create(title="Crashed Recording")
        meeting.start_recording()  # Put in RECORDING state
        assert meeting.state == MeetingState.RECORDING

        mock_uow.meetings.list_all = AsyncMock(return_value=([meeting], 1))
        mock_uow.meetings.update = AsyncMock(return_value=meeting)

        service = RecoveryService(mock_uow)
        meetings, _ = await service.recover_crashed_meetings()

        assert len(meetings) == 1, "should recover exactly one meeting"
        recovered = meetings[0]
        assert recovered.state == MeetingState.ERROR, "recovered state should be ERROR"
        assert recovered.metadata["crash_recovered"] == "true", "crash_recovered flag should be set"
        assert recovered.metadata["crash_previous_state"] == "RECORDING", "previous state should be RECORDING"
        assert "crash_recovery_time" in recovered.metadata, "recovery time should be recorded"

    async def test_recover_single_stopping_meeting(self, mock_uow: MagicMock) -> None:
        """Test recovery of a meeting left in STOPPING state."""
        meeting = Meeting.create(title="Crashed Stopping")
        meeting.start_recording()
        meeting.begin_stopping()  # Put in STOPPING state
        assert meeting.state == MeetingState.STOPPING

        mock_uow.meetings.list_all = AsyncMock(return_value=([meeting], 1))
        mock_uow.meetings.update = AsyncMock(return_value=meeting)

        service = RecoveryService(mock_uow)
        meetings, _ = await service.recover_crashed_meetings()

        assert len(meetings) == 1, "should recover exactly one meeting"
        recovered = meetings[0]
        assert recovered.state == MeetingState.ERROR, "recovered state should be ERROR"
        assert recovered.metadata["crash_previous_state"] == "STOPPING", "previous state should be STOPPING"

    async def test_recover_multiple_crashed_meetings(self, mock_uow: MagicMock) -> None:
        """Test recovery of multiple crashed meetings."""
        meeting1 = Meeting.create(title="Crashed 1")
        meeting1.start_recording()

        meeting2 = Meeting.create(title="Crashed 2")
        meeting2.start_recording()
        meeting2.begin_stopping()

        meeting3 = Meeting.create(title="Crashed 3")
        meeting3.start_recording()

        crashed_meetings = [meeting1, meeting2, meeting3]
        mock_uow.meetings.list_all = AsyncMock(return_value=(crashed_meetings, 3))
        mock_uow.meetings.update = AsyncMock(side_effect=crashed_meetings)

        service = RecoveryService(mock_uow)
        meetings, _ = await service.recover_crashed_meetings()

        assert len(meetings) == 3, "should recover all three meetings"
        assert all(m.state == MeetingState.ERROR for m in meetings), "all should be in ERROR state"
        previous_states = [m.metadata["crash_previous_state"] for m in meetings]
        assert previous_states == ["RECORDING", "STOPPING", "RECORDING"], "previous states should match"


class TestRecoveryServiceCounting:
    """Tests for counting crashed meetings."""

    async def test_count_no_crashed_meetings(self, mock_uow: MagicMock) -> None:
        """Test counting with no crashed meetings."""
        mock_uow.meetings.count_by_state = AsyncMock(return_value=0)

        service = RecoveryService(mock_uow)
        result = await service.count_crashed_meetings()

        assert result == 0
        assert mock_uow.meetings.count_by_state.call_count == 2

    async def test_count_crashed_meetings_both_states(self, mock_uow: MagicMock) -> None:
        """Test counting meetings in both active states."""

        def count_by_state(state: MeetingState) -> int:
            state_counts = {
                MeetingState.RECORDING: 3,
                MeetingState.STOPPING: 2,
            }
            return state_counts.get(state, 0)

        mock_uow.meetings.count_by_state = AsyncMock(side_effect=count_by_state)

        service = RecoveryService(mock_uow)
        result = await service.count_crashed_meetings()

        assert result == 5  # 3 RECORDING + 2 STOPPING


class TestRecoveryServiceMetadata:
    """Tests for recovery metadata handling."""

    async def test_recovery_preserves_existing_metadata(self, mock_uow: MagicMock) -> None:
        """Test recovery preserves existing meeting metadata."""
        meeting = Meeting.create(
            title="Has Metadata",
            metadata={"project": "NoteFlow", "important": "yes"},
        )
        meeting.start_recording()

        mock_uow.meetings.list_all = AsyncMock(return_value=([meeting], 1))
        mock_uow.meetings.update = AsyncMock(return_value=meeting)

        service = RecoveryService(mock_uow)
        meetings, _ = await service.recover_crashed_meetings()

        assert len(meetings) == 1, "should recover exactly one meeting"
        recovered = meetings[0]
        # Verify original metadata preserved
        assert recovered.metadata["project"] == "NoteFlow", "original project metadata preserved"
        assert recovered.metadata["important"] == "yes", "original important metadata preserved"
        # Verify recovery metadata added
        assert recovered.metadata["crash_recovered"] == "true", "crash_recovered flag set"
        assert recovered.metadata["crash_previous_state"] == "RECORDING", "previous state recorded"


class TestRecoveryServiceAudioValidation:
    """Tests for audio file validation during recovery.

    Note: Uses the global `meetings_dir` fixture from tests/conftest.py.
    """

    def test_audio_validation_skipped_whenmeetings_dir_is_none(self, mock_uow: MagicMock) -> None:
        """Test audio validation skipped when meetings_dir is None."""
        meeting = Meeting.create(title="Test Meeting")
        meeting.start_recording()

        service = RecoveryService(mock_uow, meetings_dir=None)
        result = service.validate_meeting_audio(meeting)

        assert result.is_valid is True, "should be valid when no meetings_dir"
        assert result.manifest_exists is True, "manifest should be marked as existing"
        assert result.audio_exists is True, "audio should be marked as existing"
        assert "skipped" in (result.error_message or "").lower(), "should indicate skipped"

    def test_audio_validation_missing_directory(
        self, mock_uow: MagicMock, meetings_dir: Path
    ) -> None:
        """Validate fails when meeting directory does not exist."""
        meeting = Meeting.create(title="Missing Dir")
        meeting.start_recording()

        service = RecoveryService(mock_uow, meetings_dir=meetings_dir)
        result = service.validate_meeting_audio(meeting)

        assert result.is_valid is False, "should be invalid when directory missing"
        assert result.manifest_exists is False, "manifest cannot exist without directory"
        assert result.audio_exists is False, "audio cannot exist without directory"
        assert result.error_message is not None, "error message should be present"
        assert "missing" in result.error_message.lower(), "error should mention missing directory"

    def test_audio_validation_missing_manifest(
        self, mock_uow: MagicMock, meetings_dir: Path
    ) -> None:
        """Validate fails when only audio.enc exists."""
        meeting = Meeting.create(title="Missing Manifest")
        meeting.start_recording()

        meeting_path = meetings_dir / str(meeting.id)
        meeting_path.mkdir(parents=True)
        (meeting_path / "audio.enc").touch()

        service = RecoveryService(mock_uow, meetings_dir=meetings_dir)
        result = service.validate_meeting_audio(meeting)

        assert result.is_valid is False, "should be invalid without manifest"
        assert result.manifest_exists is False, "manifest was not created"
        assert result.audio_exists is True, "audio.enc was created"
        assert result.error_message is not None, "error message should be present"
        assert "manifest.json" in result.error_message, "error should identify missing manifest"

    def test_audio_validation_missing_audio(
        self, mock_uow: MagicMock, meetings_dir: Path
    ) -> None:
        """Validate fails when only manifest.json exists."""
        meeting = Meeting.create(title="Missing Audio")
        meeting.start_recording()

        meeting_path = meetings_dir / str(meeting.id)
        meeting_path.mkdir(parents=True)
        (meeting_path / "manifest.json").touch()

        service = RecoveryService(mock_uow, meetings_dir=meetings_dir)
        result = service.validate_meeting_audio(meeting)

        assert result.is_valid is False, "should be invalid without audio"
        assert result.manifest_exists is True, "manifest.json was created"
        assert result.audio_exists is False, "audio.enc was not created"
        assert result.error_message is not None, "error message should be present"
        assert "audio.enc" in result.error_message, "error should identify missing audio"

    def test_audio_validation_success(
        self, mock_uow: MagicMock, meetings_dir: Path
    ) -> None:
        """Validate succeeds when both files exist."""
        meeting = Meeting.create(title="Complete Meeting")
        meeting.start_recording()

        meeting_path = meetings_dir / str(meeting.id)
        meeting_path.mkdir(parents=True)
        (meeting_path / "manifest.json").touch()
        (meeting_path / "audio.enc").touch()

        service = RecoveryService(mock_uow, meetings_dir=meetings_dir)
        result = service.validate_meeting_audio(meeting)

        assert result.is_valid is True, "should be valid with both files"
        assert result.manifest_exists is True, "manifest.json was created"
        assert result.audio_exists is True, "audio.enc was created"
        assert result.error_message is None, "no error when validation passes"

    def test_audio_validation_uses_asset_path_metadata(
        self, mock_uow: MagicMock, meetings_dir: Path
    ) -> None:
        """Test validation uses asset_path from metadata if available."""
        meeting = Meeting.create(
            title="Custom Path",
            metadata={"asset_path": "custom-path-123"},
        )
        meeting.start_recording()

        # Create meeting at custom asset path
        meeting_path = meetings_dir / "custom-path-123"
        meeting_path.mkdir(parents=True)
        (meeting_path / "manifest.json").touch()
        (meeting_path / "audio.enc").touch()

        service = RecoveryService(mock_uow, meetings_dir=meetings_dir)
        result = service.validate_meeting_audio(meeting)

        assert result.is_valid is True

    async def test_recovery_counts_audio_failures(
        self, mock_uow: MagicMock, meetings_dir: Path
    ) -> None:
        """Test recovery tracks audio validation failure count."""
        meeting1 = Meeting.create(title="Has Audio")
        meeting1.start_recording()

        meeting2 = Meeting.create(title="Missing Audio")
        meeting2.start_recording()

        # Create directory for meeting1 only
        meeting1_path = meetings_dir / str(meeting1.id)
        meeting1_path.mkdir(parents=True)
        (meeting1_path / "manifest.json").touch()
        (meeting1_path / "audio.enc").touch()

        mock_uow.meetings.list_all = AsyncMock(return_value=([meeting1, meeting2], 2))
        mock_uow.meetings.update = AsyncMock(side_effect=[meeting1, meeting2])

        service = RecoveryService(mock_uow, meetings_dir=meetings_dir)
        meetings, audio_failures = await service.recover_crashed_meetings()

        assert len(meetings) == 2, "should recover both meetings"
        assert audio_failures == 1, "should have 1 audio failure"
        assert meetings[0].metadata["audio_valid"] == "true", "meeting1 audio should be valid"
        assert meetings[1].metadata["audio_valid"] == "false", "meeting2 audio should be invalid"
        assert "audio_error" in meetings[1].metadata, "meeting2 should have audio_error"


class TestAudioValidationResultInvariants:
    """Tests for AudioValidationResult behavioral invariants.

    These tests verify semantic constraints that the recovery service
    relies upon for correct operation.
    """

    def test_valid_result_implies_both_files_exist(self) -> None:
        """A valid result must have both manifest and audio present.

        The recovery service relies on this invariant to know that
        a recovered meeting has complete audio data.
        """
        result = AudioValidationResult(
            is_valid=True,
            manifest_exists=True,
            audio_exists=True,
        )

        assert result.is_valid is True, "valid result requires is_valid=True"
        assert result.manifest_exists is True, "valid result requires manifest"
        assert result.audio_exists is True, "valid result requires audio"
        assert result.error_message is None, "valid result has no error"

    def test_invalid_result_carries_diagnostic_error(self) -> None:
        """Invalid results include error message for user feedback.

        The recovery service displays these errors to help users
        understand why a meeting cannot be recovered.
        """
        result = AudioValidationResult(
            is_valid=False,
            manifest_exists=False,
            audio_exists=False,
            error_message="Meeting directory missing",
        )

        assert result.is_valid is False, "missing files means invalid"
        assert result.error_message is not None, "invalid result should have error"
        assert "missing" in result.error_message.lower(), "error describes the issue"

    def test_partial_files_reported_for_recovery_guidance(self) -> None:
        """Partial file presence guides manual recovery.

        When manifest exists but audio is missing, users know they
        have metadata but lost audio data.
        """
        result = AudioValidationResult(
            is_valid=False,
            manifest_exists=True,
            audio_exists=False,
            error_message="audio.enc not found",
        )

        assert result.manifest_exists is True, "manifest present for metadata recovery"
        assert result.audio_exists is False, "audio missing indicates data loss"
        assert result.error_message is not None, "error message should be present"
        assert "audio" in result.error_message.lower(), "error identifies missing file"
