"""Integration tests for RecoveryService.

Tests crash recovery orchestration against a real database including:
- Meeting recovery (RECORDING/STOPPING -> ERROR)
- Diarization job recovery
- Full recovery workflow
- Audio validation integration
- Edge cases and error handling
"""

from __future__ import annotations

import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
from uuid import uuid4

import pytest

from noteflow.application.services.recovery_service import RecoveryResult, RecoveryService
from noteflow.domain.entities import Meeting
from noteflow.domain.value_objects import MeetingState
from noteflow.infrastructure.persistence.repositories import DiarizationJob
from noteflow.infrastructure.persistence.repositories.diarization_job_repo import (
    JOB_STATUS_COMPLETED,
    JOB_STATUS_FAILED,
    JOB_STATUS_QUEUED,
    JOB_STATUS_RUNNING,
)
from noteflow.infrastructure.persistence.unit_of_work import SqlAlchemyUnitOfWork

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker


@pytest.mark.integration
class TestRecoveryServiceMeetingRecovery:
    """Integration tests for meeting crash recovery."""

    async def test_recovers_recording_meeting(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovering a meeting stuck in RECORDING state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Crashed Recording")
            meeting.start_recording()
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _audio_failures = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == 1, "should recover exactly one crashed meeting"
        assert recovered[0].id == meeting.id, f"recovered meeting ID should match: expected {meeting.id}"
        assert recovered[0].state == MeetingState.ERROR, "recovered meeting should be in ERROR state"
        assert recovered[0].metadata["crash_recovered"] == "true", "crash_recovered metadata should be 'true'"
        assert recovered[0].metadata["crash_previous_state"] == "RECORDING", "crash_previous_state should be 'RECORDING'"

    async def test_recovers_stopping_meeting(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovering a meeting stuck in STOPPING state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Crashed Stopping")
            meeting.start_recording()
            meeting.begin_stopping()
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _ = await recovery_service.recover_crashed_meetings()

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

    async def test_ignores_created_meetings(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovery ignores meetings in CREATED state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Just Created")
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _ = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == 0, "should not recover any meetings when only CREATED state exists"

        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            m = await uow.meetings.get(meeting.id)
            assert m is not None, f"meeting {meeting.id} should exist in database"
            assert m.state == MeetingState.CREATED, f"meeting should remain in CREATED state, got {m.state}"

    async def test_ignores_stopped_meetings(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovery ignores meetings in STOPPED state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Cleanly Stopped")
            meeting.start_recording()
            meeting.begin_stopping()
            meeting.stop_recording()
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _ = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == 0, "should not recover any meetings when only STOPPED state exists"

    async def test_ignores_error_meetings(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovery ignores meetings already in ERROR state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Already Error")
            meeting.start_recording()
            meeting.mark_error()
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _ = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == 0, "should not recover any meetings when already in ERROR state"

    async def test_recovers_multiple_meetings(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovering multiple crashed meetings at once."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting0 = Meeting.create(title="Crashed Meeting 0")
            meeting0.start_recording()
            await uow.meetings.create(meeting0)

            meeting1 = Meeting.create(title="Crashed Meeting 1")
            meeting1.start_recording()
            await uow.meetings.create(meeting1)

            meeting2 = Meeting.create(title="Crashed Meeting 2")
            meeting2.start_recording()
            await uow.meetings.create(meeting2)

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

            meeting4 = Meeting.create(title="Crashed Meeting 4")
            meeting4.start_recording()
            await uow.meetings.create(meeting4)

            await uow.commit()
            expected_ids = {meeting0.id, meeting1.id, meeting2.id, meeting3.id, meeting4.id}

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _ = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == len(expected_ids), f"should recover all crashed meetings, got {len(recovered)}"

        recovered_ids = {m.id for m in recovered}
        assert recovered_ids == expected_ids, f"recovered meeting IDs should match expected: {expected_ids}"

        recovered_states = {m.state for m in recovered}
        assert recovered_states == {MeetingState.ERROR}, f"all recovered meetings should be in ERROR state, got {recovered_states}"

    async def test_recovery_metadata_includes_timestamp(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovery metadata includes crash recovery timestamp."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Crashed")
            meeting.start_recording()
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, _ = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == 1, "should recover exactly one crashed meeting"
        assert "crash_recovery_time" in recovered[0].metadata, "recovered meeting metadata should include crash_recovery_time"
        assert recovered[0].metadata["crash_recovery_time"], "crash_recovery_time should have a non-empty value"


@pytest.mark.integration
class TestRecoveryServiceDiarizationJobRecovery:
    """Integration tests for diarization job crash recovery."""

    async def test_recovers_queueddiarization_jobs(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovering diarization jobs stuck in QUEUED state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create()
            await uow.meetings.create(meeting)

            job = DiarizationJob(
                job_id=str(uuid4()),
                meeting_id=str(meeting.id),
                status=JOB_STATUS_QUEUED,
            )
            await uow.diarization_jobs.create(job)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        failed_count = await recovery_service.recover_crashed_diarization_jobs()

        assert failed_count == 1, "should fail exactly one queued job"

        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            retrieved = await uow.diarization_jobs.get(job.job_id)
            assert retrieved is not None, "recovered job should exist in database"
            assert retrieved.status == JOB_STATUS_FAILED, "recovered job should be in FAILED status"
            assert "Server restarted" in retrieved.error_message, "error message should indicate server restart"

    async def test_recovers_runningdiarization_jobs(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovering diarization jobs stuck in RUNNING state."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create()
            await uow.meetings.create(meeting)

            job = DiarizationJob(
                job_id=str(uuid4()),
                meeting_id=str(meeting.id),
                status=JOB_STATUS_RUNNING,
            )
            await uow.diarization_jobs.create(job)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        failed_count = await recovery_service.recover_crashed_diarization_jobs()

        assert failed_count == 1, "should fail exactly one running diarization job"

    async def test_ignores_completeddiarization_jobs(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovery ignores COMPLETED diarization jobs."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create()
            await uow.meetings.create(meeting)

            job = DiarizationJob(
                job_id=str(uuid4()),
                meeting_id=str(meeting.id),
                status=JOB_STATUS_COMPLETED,
                segments_updated=10,
            )
            await uow.diarization_jobs.create(job)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        failed_count = await recovery_service.recover_crashed_diarization_jobs()

        assert failed_count == 0, "should not fail any completed diarization jobs"

        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            retrieved = await uow.diarization_jobs.get(job.job_id)
            assert retrieved is not None, f"diarization job {job.job_id} should exist in database"
            assert retrieved.status == JOB_STATUS_COMPLETED, f"completed job should remain COMPLETED, got {retrieved.status}"

    async def test_ignores_already_faileddiarization_jobs(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recovery ignores already FAILED diarization jobs."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create()
            await uow.meetings.create(meeting)

            job = DiarizationJob(
                job_id=str(uuid4()),
                meeting_id=str(meeting.id),
                status=JOB_STATUS_FAILED,
                error_message="Original failure",
            )
            await uow.diarization_jobs.create(job)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        failed_count = await recovery_service.recover_crashed_diarization_jobs()

        assert failed_count == 0, "should not fail any already-failed diarization jobs"

        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            retrieved = await uow.diarization_jobs.get(job.job_id)
            assert retrieved is not None, f"diarization job {job.job_id} should exist in database"
            assert retrieved.error_message == "Original failure", f"original error message should be preserved, got '{retrieved.error_message}'"


@pytest.mark.integration
class TestRecoveryServiceFullRecovery:
    """Integration tests for complete recovery workflow."""

    async def test_recover_all_orchestrates_both_recoveries(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recover_all handles both meetings and diarization jobs."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting1 = Meeting.create(title="Crashed 1")
            meeting1.start_recording()
            meeting2 = Meeting.create(title="Crashed 2")
            meeting2.start_recording()
            await uow.meetings.create(meeting1)
            await uow.meetings.create(meeting2)

            job = DiarizationJob(
                job_id=str(uuid4()),
                meeting_id=str(meeting1.id),
                status=JOB_STATUS_RUNNING,
            )
            await uow.diarization_jobs.create(job)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        result = await recovery_service.recover_all()

        assert isinstance(result, RecoveryResult), "recover_all should return RecoveryResult"
        assert result.meetings_recovered == 2, "should recover 2 crashed meetings"
        assert result.diarization_jobs_failed == 1, "should fail 1 crashed diarization job"
        assert result.total_recovered == 3, "total_recovered should be sum of meetings and jobs"

    async def test_recover_all_with_nothing_to_recover(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test recover_all when there's nothing to recover."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="Clean")
            await uow.meetings.create(meeting)

            job = DiarizationJob(
                job_id=str(uuid4()),
                meeting_id=str(meeting.id),
                status=JOB_STATUS_COMPLETED,
            )
            await uow.diarization_jobs.create(job)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        result = await recovery_service.recover_all()

        assert result.meetings_recovered == 0, "should not recover any meetings when none crashed"
        assert result.diarization_jobs_failed == 0, "should not fail any diarization jobs when none crashed"
        assert result.total_recovered == 0, "total_recovered should be 0 when nothing to recover"


@pytest.mark.integration
class TestRecoveryServiceCounting:
    """Integration tests for crash count queries."""

    async def test_count_crashed_meetings_accurate(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test count_crashed_meetings returns accurate count."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            # CREATED state meeting
            meeting_created = Meeting.create(title="Created")
            await uow.meetings.create(meeting_created)

            # First RECORDING state meeting
            meeting_recording1 = Meeting.create(title="Recording 1")
            meeting_recording1.start_recording()
            await uow.meetings.create(meeting_recording1)

            # Second RECORDING state meeting
            meeting_recording2 = Meeting.create(title="Recording 2")
            meeting_recording2.start_recording()
            await uow.meetings.create(meeting_recording2)

            # STOPPING state meeting
            meeting_stopping = Meeting.create(title="Stopping")
            meeting_stopping.start_recording()
            meeting_stopping.begin_stopping()
            await uow.meetings.create(meeting_stopping)

            # STOPPED state meeting
            meeting_stopped = Meeting.create(title="Stopped")
            meeting_stopped.start_recording()
            meeting_stopped.begin_stopping()
            meeting_stopped.stop_recording()
            await uow.meetings.create(meeting_stopped)

            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        count = await recovery_service.count_crashed_meetings()

        assert count == 3, f"should count 3 crashed meetings (2 RECORDING + 1 STOPPING), got {count}"


@pytest.mark.integration
class TestRecoveryServiceAudioValidation:
    """Integration tests for audio file validation during recovery."""

    async def test_audio_validation_with_valid_files(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test audio validation passes when manifest and audio exist."""
        with tempfile.TemporaryDirectory() as tmpdir:
            meetings_dir = Path(tmpdir)

            async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
                meeting = Meeting.create(title="With Audio")
                meeting.start_recording()
                meeting.asset_path = str(meeting.id)
                await uow.meetings.create(meeting)
                await uow.commit()

                meeting_dir = meetings_dir / str(meeting.id)
                meeting_dir.mkdir(parents=True)
                (meeting_dir / "manifest.json").write_text("{}")
                (meeting_dir / "audio.enc").write_bytes(b"encrypted_audio")

            recovery_service = RecoveryService(
                SqlAlchemyUnitOfWork(session_factory, meetings_dir),
                meetings_dir=meetings_dir,
            )
            recovered, audio_failures = await recovery_service.recover_crashed_meetings()

            assert len(recovered) == 1, "should recover exactly one meeting"
            assert audio_failures == 0, "should report no audio validation failures when files exist"
            assert recovered[0].metadata["audio_valid"] == "true", "audio_valid should be 'true' when manifest and audio.enc exist"

    async def test_audio_validation_with_missing_audio(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test audio validation fails when audio.enc is missing."""
        with tempfile.TemporaryDirectory() as tmpdir:
            meetings_dir = Path(tmpdir)

            async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
                meeting = Meeting.create(title="Missing Audio")
                meeting.start_recording()
                meeting.asset_path = str(meeting.id)
                await uow.meetings.create(meeting)
                await uow.commit()

                meeting_dir = meetings_dir / str(meeting.id)
                meeting_dir.mkdir(parents=True)
                (meeting_dir / "manifest.json").write_text("{}")

            recovery_service = RecoveryService(
                SqlAlchemyUnitOfWork(session_factory, meetings_dir),
                meetings_dir=meetings_dir,
            )
            recovered, audio_failures = await recovery_service.recover_crashed_meetings()

            assert len(recovered) == 1, "should recover exactly one meeting"
            assert audio_failures == 1, "should report one audio validation failure"
            assert recovered[0].metadata["audio_valid"] == "false", "audio_valid should be 'false' when audio.enc missing"
            assert "audio.enc not found" in recovered[0].metadata["audio_error"], "audio_error should indicate missing audio.enc"

    async def test_audio_validation_with_missing_manifest(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test audio validation fails when manifest.json is missing."""
        with tempfile.TemporaryDirectory() as tmpdir:
            meetings_dir = Path(tmpdir)

            async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
                meeting = Meeting.create(title="Missing Manifest")
                meeting.start_recording()
                meeting.asset_path = str(meeting.id)
                await uow.meetings.create(meeting)
                await uow.commit()

                meeting_dir = meetings_dir / str(meeting.id)
                meeting_dir.mkdir(parents=True)
                (meeting_dir / "audio.enc").write_bytes(b"audio")

            recovery_service = RecoveryService(
                SqlAlchemyUnitOfWork(session_factory, meetings_dir),
                meetings_dir=meetings_dir,
            )
            recovered, audio_failures = await recovery_service.recover_crashed_meetings()

            assert len(recovered) == 1, "should recover exactly one meeting"
            assert audio_failures == 1, "should report one audio validation failure"
            assert recovered[0].metadata["audio_valid"] == "false", "audio_valid should be 'false' when manifest missing"
            assert "manifest.json not found" in recovered[0].metadata["audio_error"], "audio_error should indicate missing manifest.json"

    async def test_audio_validation_with_missing_directory(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test audio validation fails when meeting directory doesn't exist."""
        with tempfile.TemporaryDirectory() as tmpdir:
            meetings_dir = Path(tmpdir)

            async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
                meeting = Meeting.create(title="No Directory")
                meeting.start_recording()
                meeting.asset_path = str(meeting.id)
                await uow.meetings.create(meeting)
                await uow.commit()

            recovery_service = RecoveryService(
                SqlAlchemyUnitOfWork(session_factory, meetings_dir),
                meetings_dir=meetings_dir,
            )
            recovered, audio_failures = await recovery_service.recover_crashed_meetings()

            assert len(recovered) == 1, "should recover exactly one meeting"
            assert audio_failures == 1, "should report one audio validation failure when directory missing"
            assert recovered[0].metadata["audio_valid"] == "false", "audio_valid should be 'false' when meeting directory missing"

    async def test_audio_validation_skipped_withoutmeetings_dir(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test audio validation is skipped when meetings_dir not provided."""
        async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
            meeting = Meeting.create(title="No Validation")
            meeting.start_recording()
            await uow.meetings.create(meeting)
            await uow.commit()

        recovery_service = RecoveryService(SqlAlchemyUnitOfWork(session_factory, meetings_dir))
        recovered, audio_failures = await recovery_service.recover_crashed_meetings()

        assert len(recovered) == 1, "should recover exactly one meeting"
        assert audio_failures == 0, "should report no audio validation failures when meetings_dir not provided"
        assert recovered[0].metadata["audio_valid"] == "true", "audio_valid should default to 'true' when validation skipped"

    async def test_audio_validation_uses_asset_path(
        self, session_factory: async_sessionmaker[AsyncSession], meetings_dir: Path
    ) -> None:
        """Test audio validation respects custom asset_path."""
        with tempfile.TemporaryDirectory() as tmpdir:
            meetings_dir = Path(tmpdir)
            custom_path = "2024/01/my-meeting"

            async with SqlAlchemyUnitOfWork(session_factory, meetings_dir) as uow:
                meeting = Meeting.create(title="Custom Path")
                meeting.start_recording()
                meeting.asset_path = custom_path
                await uow.meetings.create(meeting)
                await uow.commit()

                meeting_dir = meetings_dir / custom_path
                meeting_dir.mkdir(parents=True)
                (meeting_dir / "manifest.json").write_text("{}")
                (meeting_dir / "audio.enc").write_bytes(b"audio")

            recovery_service = RecoveryService(
                SqlAlchemyUnitOfWork(session_factory, meetings_dir),
                meetings_dir=meetings_dir,
            )
            recovered, audio_failures = await recovery_service.recover_crashed_meetings()

            assert len(recovered) == 1, "should recover exactly one meeting"
            assert audio_failures == 0, "should report no audio validation failures when custom path files exist"
            assert recovered[0].metadata["audio_valid"] == "true", "audio_valid should be 'true' when files exist at custom asset_path"
