"""Recovery service for crash recovery on startup.

Detect and recover meetings left in active states after server restart.
Optionally validate audio file integrity for crashed meetings.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar

import sqlalchemy.exc

from noteflow.domain.value_objects import MeetingState
from noteflow.infrastructure.logging import get_logger, log_state_transition
from noteflow.infrastructure.persistence.constants import MAX_MEETINGS_LIMIT

if TYPE_CHECKING:
    from noteflow.domain.entities import Meeting
    from noteflow.domain.ports.unit_of_work import UnitOfWork

logger = get_logger(__name__)


@dataclass(frozen=True)
class AudioValidationResult:
    """Result of audio file validation for a meeting."""

    is_valid: bool
    manifest_exists: bool
    audio_exists: bool
    error_message: str | None = None


@dataclass(frozen=True)
class RecoveryResult:
    """Result of crash recovery operations."""

    meetings_recovered: int
    diarization_jobs_failed: int
    audio_validation_failures: int = 0

    @property
    def total_recovered(self) -> int:
        """Total items recovered across all types."""
        return self.meetings_recovered + self.diarization_jobs_failed


class RecoveryService:
    """Recover meetings from crash states on server startup.

    Find meetings left in RECORDING or STOPPING state and mark them as ERROR.
    This handles the case where the server crashed during an active meeting.

    Optionally validates audio file integrity if crypto and meetings_dir are provided.
    """

    ACTIVE_STATES: ClassVar[list[MeetingState]] = [
        MeetingState.RECORDING,
        MeetingState.STOPPING,
    ]

    def __init__(
        self,
        uow: UnitOfWork,
        meetings_dir: Path | None = None,
    ) -> None:
        """Initialize recovery service.

        Args:
            uow: Unit of work for persistence.
            meetings_dir: Optional meetings directory for audio validation.
                If provided, validates that audio files exist for crashed meetings.
        """
        self._uow = uow
        self._meetings_dir = meetings_dir

    def validate_meeting_audio(self, meeting: Meeting) -> AudioValidationResult:
        """Validate audio files for a crashed meeting.

        Check that manifest.json and audio.enc exist in the meeting directory.

        Args:
            meeting: Meeting to validate.

        Returns:
            AudioValidationResult with validation status.
        """
        if self._meetings_dir is None:
            return AudioValidationResult(
                is_valid=True,
                manifest_exists=True,
                audio_exists=True,
                error_message="Audio validation skipped (no meetings_dir configured)",
            )

        # Prefer explicit asset_path; fall back to metadata for backward compatibility
        default_path = str(meeting.id)
        asset_path = meeting.asset_path or default_path
        if asset_path == default_path:
            asset_path = meeting.metadata.get("asset_path") or asset_path
        meeting_dir = self._meetings_dir / asset_path

        manifest_path = meeting_dir / "manifest.json"
        audio_path = meeting_dir / "audio.enc"

        manifest_exists = manifest_path.exists()
        audio_exists = audio_path.exists()

        if not manifest_exists and not audio_exists:
            return AudioValidationResult(
                is_valid=False,
                manifest_exists=False,
                audio_exists=False,
                error_message="Meeting directory missing or empty",
            )

        if not manifest_exists:
            return AudioValidationResult(
                is_valid=False,
                manifest_exists=False,
                audio_exists=audio_exists,
                error_message="manifest.json not found",
            )

        if not audio_exists:
            return AudioValidationResult(
                is_valid=False,
                manifest_exists=True,
                audio_exists=False,
                error_message="audio.enc not found",
            )

        return AudioValidationResult(
            is_valid=True,
            manifest_exists=True,
            audio_exists=True,
        )

    async def recover_crashed_meetings(self) -> tuple[list[Meeting], int]:
        """Find and recover meetings left in active states.

        Mark all meetings in RECORDING or STOPPING state as ERROR
        with metadata explaining the crash recovery. Also validates
        audio file integrity if meetings_dir is configured.

        Returns:
            Tuple of (recovered meetings, audio validation failure count).
        """
        async with self._uow:
            # Find all meetings in active states
            meetings, total = await self._uow.meetings.list_all(
                states=self.ACTIVE_STATES,
                limit=MAX_MEETINGS_LIMIT,
            )

            if total == 0:
                logger.info("No crashed meetings found during recovery")
                return [], 0

            logger.warning(
                "Found %d meetings in active state during startup, marking as ERROR",
                total,
            )

            recovered: list[Meeting] = []
            audio_failures = 0
            recovery_time = datetime.now(UTC).isoformat()

            for meeting in meetings:
                previous_state = meeting.state
                meeting.mark_error()
                log_state_transition(
                    "meeting",
                    str(meeting.id),
                    previous_state,
                    meeting.state,
                    reason="crash_recovery",
                )

                # Add crash recovery metadata
                meeting.metadata["crash_recovered"] = "true"
                meeting.metadata["crash_recovery_time"] = recovery_time
                meeting.metadata["crash_previous_state"] = previous_state.name

                # Validate audio files if configured
                validation = self.validate_meeting_audio(meeting)
                meeting.metadata["audio_valid"] = str(validation.is_valid).lower()
                if not validation.is_valid:
                    audio_failures += 1
                    meeting.metadata["audio_error"] = validation.error_message or "unknown"
                    logger.warning(
                        "Audio validation failed for meeting %s: %s",
                        meeting.id,
                        validation.error_message,
                    )

                await self._uow.meetings.update(meeting)
                recovered.append(meeting)

                logger.info(
                    "Recovered crashed meeting: id=%s, previous_state=%s, audio_valid=%s",
                    meeting.id,
                    previous_state,
                    validation.is_valid,
                )

            await self._uow.commit()
            logger.info(
                "Crash recovery complete: %d meetings recovered, %d audio failures",
                len(recovered),
                audio_failures,
            )
            return recovered, audio_failures

    async def count_crashed_meetings(self) -> int:
        """Count meetings currently in crash states.

        Returns:
            Number of meetings in RECORDING or STOPPING state.
        """
        async with self._uow:
            total = 0
            for state in self.ACTIVE_STATES:
                total += await self._uow.meetings.count_by_state(state)
            return total

    async def recover_crashed_diarization_jobs(self) -> int:
        """Mark diarization jobs left in running states as failed.

        Find all diarization jobs in QUEUED or RUNNING state and mark them
        as FAILED with an error message explaining the crash recovery.

        Returns:
            Number of jobs marked as failed.
        """
        try:
            async with self._uow:
                failed_count = await self._uow.diarization_jobs.mark_running_as_failed()
                await self._uow.commit()

                if failed_count > 0:
                    logger.warning(
                        "Marked %d diarization jobs as failed during crash recovery",
                        failed_count,
                    )
                else:
                    logger.info("No crashed diarization jobs found during recovery")

                return failed_count
        except sqlalchemy.exc.ProgrammingError as e:
            # Handle case where diarization_jobs table doesn't exist yet
            # (e.g., schema.sql partially applied, migrations not run)
            if "does not exist" in str(e) or "UndefinedTableError" in str(e):
                logger.debug(
                    "Diarization jobs table not found during recovery, skipping: %s",
                    e,
                )
                return 0
            raise

    async def recover_all(self) -> RecoveryResult:
        """Run all crash recovery operations.

        Recovers crashed meetings and failed diarization jobs in a single
        coordinated operation.

        Returns:
            RecoveryResult with counts of recovered items.
        """
        meetings, audio_failures = await self.recover_crashed_meetings()
        jobs = await self.recover_crashed_diarization_jobs()

        result = RecoveryResult(
            meetings_recovered=len(meetings),
            diarization_jobs_failed=jobs,
            audio_validation_failures=audio_failures,
        )

        if result.total_recovered > 0:
            logger.warning(
                "Crash recovery complete: %d meetings, %d diarization jobs",
                result.meetings_recovered,
                result.diarization_jobs_failed,
            )

        return result
