from __future__ import annotations

import logging
import shutil
import tempfile
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from logging import FileHandler
from pathlib import Path
from typing import Any

import soundfile as sf

import svx.core.config as config
from svx.core.audio import convert_audio, record_wav, timestamp
from svx.core.chunking import (
    ChunkInfo,
    get_audio_duration,
    merge_segments,
    merge_texts,
    split_audio,
)
from svx.core.clipboard import copy_to_clipboard
from svx.core.config import Config
from svx.core.formatting import format_diarized_transcript
from svx.core.storage import save_text_file, save_transcript
from svx.providers import get_provider
from svx.providers.base import Provider, TranscriptionResult, TranscriptionSegment


def _needs_conversion(input_path: Path, audio_format: str) -> bool:
    """
    Return True if the file must be converted to reach the target audio format.

    Skips conversion when the file is already in a compatible format:
    - 'mp3' target: skip for .mp3 inputs
    - 'opus' target: skip for .opus inputs and .ogg inputs (OGG is a container that
      commonly carries Opus audio and is accepted by transcription APIs directly)
    """
    if audio_format not in {"mp3", "opus"}:
        return False
    ext = input_path.suffix.lower().lstrip(".")
    if audio_format == "mp3" and ext == "mp3":
        return False
    if audio_format == "opus" and ext in ("opus", "ogg"):
        return False
    return True


class RecordingPipeline:
    """
    Centralized pipeline for recording audio, transcribing via provider, optionally
    transforming with a text LLM, saving outputs, and copying to clipboard.

    Pipeline steps:
    1. Transcription: audio -> text via dedicated transcription endpoint (always)
       - Supports diarization (speaker identification)
       - Auto-chunks long recordings (> chunk_duration) with overlap
    2. Transformation: text + prompt -> text via text-based LLM (when a prompt is provided)

    Handles temporary files when not keeping audio.
    Supports runtime overrides like save_all for keeping all files and adding log handlers.
    Optional progress_callback for status updates (e.g., for GUI).
    Supports transcribe_mode for pure transcription without prompt (step 1 only).
    Supports dual-device recording (mic + loopback) when loopback_device is configured.
    """

    def __init__(
        self,
        cfg: Config,
        user_prompt: str | None = None,
        user_prompt_file: Path | None = None,
        save_all: bool = False,
        outfile_prefix: str | None = None,
        progress_callback: Callable[[str], None] | None = None,
        transcribe_mode: bool = False,
        level_monitor: object | None = None,
    ) -> None:
        self.cfg = cfg
        self.user_prompt = user_prompt
        self.user_prompt_file = user_prompt_file
        self.save_all = save_all
        self.outfile_prefix = outfile_prefix
        self.progress_callback = progress_callback
        self.transcribe_mode = transcribe_mode
        self.level_monitor = level_monitor
        self._chunk_dir: Path | None = None  # temp dir for chunk files
        self._convert_dir: Path | None = None  # temp dir for conversion output
        self._recording_base: str | None = None  # base name set during record()

    def _status(self, msg: str) -> None:
        """Emit status update via callback if provided."""
        if self.progress_callback:
            self.progress_callback(msg)
        logging.info(msg)

    def record(self, stop_event: threading.Event | None = None) -> tuple[Path, float]:
        """
        Record audio and return wav_path, duration.

        Uses dual-device recording if loopback_device is configured.

        Returns:
            tuple[Path, float]: wav_path, duration.
        """
        rate = self.cfg.defaults.rate
        channels = self.cfg.defaults.channels
        device = self.cfg.defaults.device
        audio_format = self.cfg.defaults.format
        base = self.outfile_prefix or f"rec_{timestamp()}"
        self._recording_base = base
        keep_raw = self.save_all or self.cfg.defaults.keep_raw_audio
        loopback_device = self.cfg.defaults.loopback_device

        # Validation (fail fast)
        if channels not in (1, 2):
            raise ValueError("channels must be 1 or 2")
        if rate <= 0:
            raise ValueError("rate must be > 0")
        if audio_format not in {"wav", "mp3", "opus"}:
            raise ValueError("format must be one of wav|mp3|opus")

        stop_for_recording = stop_event or threading.Event()

        # Determine output path
        if keep_raw:
            self.cfg.recordings_dir.mkdir(parents=True, exist_ok=True)
            wav_path = self.cfg.recordings_dir / f"{base}.wav"
        else:
            wav_path = Path(tempfile.mktemp(suffix=".wav"))

        # Dual-device or single-device recording
        if loopback_device:
            from svx.core.meeting_audio import find_loopback_device, record_dual_wav

            self._status("Recording (dual: mic + loopback)...")
            loop_idx = find_loopback_device(loopback_device)
            if loop_idx is None:
                raise ValueError(
                    f"Loopback device '{loopback_device}' not found. "
                    "Check your audio configuration."
                )
            _mic_cb = getattr(self.level_monitor, "push_mic", None)
            _loop_cb = getattr(self.level_monitor, "push_loop", None)
            duration = record_dual_wav(
                wav_path,
                mic_device=device,
                loopback_device=loop_idx,
                samplerate=rate,
                stop_event=stop_for_recording,
                mic_gain=self.cfg.defaults.mic_gain,
                loopback_gain=self.cfg.defaults.loopback_gain,
                mic_level_cb=_mic_cb,
                loop_level_cb=_loop_cb,
            )
        else:
            self._status("Recording...")
            _mic_cb = getattr(self.level_monitor, "push_mic", None)
            duration = record_wav(
                wav_path,
                samplerate=rate,
                channels=channels,
                device=device,
                stop_event=stop_for_recording,
                level_callback=_mic_cb,
            )

        self._status("Recording completed.")
        return wav_path, duration

    def _setup_save_all(self) -> None:
        """Apply save_all overrides: set keeps to True, create dirs, add file logging."""
        if not self.save_all:
            return

        # Override config defaults
        self.cfg.defaults.keep_raw_audio = True
        self.cfg.defaults.keep_compressed_audio = True
        self.cfg.defaults.keep_transcript_files = True
        self.cfg.defaults.keep_log_files = True

        self._ensure_output_dirs()

    def _ensure_output_dirs(self) -> None:
        """Create output directories and add file logging if needed."""
        config.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
        config.TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
        config.LOGS_DIR.mkdir(parents=True, exist_ok=True)

        # Add file handler if not present
        root_logger = logging.getLogger()
        if not any(isinstance(h, FileHandler) for h in root_logger.handlers):  # type: ignore[reportUnknownMemberType]
            from svx.core.config import _get_log_level

            log_level_int = _get_log_level(self.cfg.defaults.log_level)
            formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
            file_handler = logging.FileHandler(config.LOGS_DIR / "app.log", encoding="utf-8")
            file_handler.setLevel(log_level_int)
            file_handler.setFormatter(formatter)
            root_logger.addHandler(file_handler)
            logging.info("File logging enabled for this run")

    def _activate_save_all_for_long_recording(self, duration: float) -> None:
        """Auto-activate save_all for long recordings to protect against data loss."""
        chunk_duration = self.cfg.defaults.chunk_duration
        if duration > chunk_duration and not self.save_all:
            logging.info(
                "Long recording (%.1fs > %ds): auto-activating save_all for data protection",
                duration,
                chunk_duration,
            )
            self.save_all = True
            self.cfg.defaults.keep_raw_audio = True
            self.cfg.defaults.keep_compressed_audio = True
            self.cfg.defaults.keep_transcript_files = True
            self.cfg.defaults.keep_log_files = True
            self._ensure_output_dirs()

    def _transcribe_single(
        self,
        audio_path: Path,
        provider_name: str,
        model: str,
        language: str | None,
        diarize: bool,
        prov: Provider | None = None,
    ) -> TranscriptionResult:
        """Transcribe a single audio file. Reuses `prov` if given."""
        if prov is None:
            prov = get_provider(provider_name, cfg=self.cfg)
        return prov.transcribe(
            audio_path,
            model=model,
            language=language,
            diarize=diarize,
            timestamp_granularities=["segment"] if diarize else None,
        )

    def _transcribe_chunked(
        self,
        audio_path: Path,
        provider_name: str,
        model: str,
        language: str | None,
        diarize: bool,
        base: str,
    ) -> TranscriptionResult:
        """Split audio into chunks, transcribe each, and merge results."""
        chunk_duration = self.cfg.defaults.chunk_duration
        chunk_overlap = self.cfg.defaults.chunk_overlap
        keep_transcript = self.save_all or self.cfg.defaults.keep_transcript_files

        self._status(f"Splitting audio into {chunk_duration}s chunks...")
        chunks = split_audio(audio_path, chunk_duration=chunk_duration, overlap=chunk_overlap)
        self._chunk_dir = chunks[0].path.parent if chunks and chunks[0].path != audio_path else None

        def _transcribe_chunk(chunk: ChunkInfo) -> tuple[int, TranscriptionResult]:
            prov = get_provider(provider_name, cfg=self.cfg)
            result = self._transcribe_single(
                chunk.path, provider_name, model, language, diarize, prov=prov
            )
            if keep_transcript:
                self.cfg.transcripts_dir.mkdir(parents=True, exist_ok=True)
                save_text_file(
                    self.cfg.transcripts_dir / f"{base}_chunk{chunk.index:03d}.txt",
                    result["text"],
                )
            return chunk.index, result

        self._status(f"Transcribing {len(chunks)} chunks (up to 4 in parallel)...")
        results_by_index: dict[int, TranscriptionResult] = {}
        with ThreadPoolExecutor(max_workers=4) as executor:
            futures = {executor.submit(_transcribe_chunk, chunk): chunk for chunk in chunks}
            for future in as_completed(futures):
                idx, result = future.result()
                results_by_index[idx] = result
                self._status(f"Chunk {idx + 1}/{len(chunks)} done.")

        all_texts: list[str] = []
        all_raws: list[dict[str, Any]] = []
        all_segments: list[list[TranscriptionSegment]] = []
        for chunk in chunks:
            result = results_by_index[chunk.index]
            all_texts.append(result["text"])
            all_raws.append(result["raw"])
            if "segments" in result:
                all_segments.append(result["segments"])

        # Merge results
        merged_raw: dict[str, Any] = {"chunks": all_raws}
        if all_segments and len(all_segments) == len(chunks):
            merged_segs = merge_segments(chunks, all_segments)
            merged_text = merge_texts(chunks, all_texts, chunk_overlap)
            merged_result = TranscriptionResult(text=merged_text, raw=merged_raw)
            merged_result["segments"] = merged_segs
            return merged_result
        else:
            merged_text = merge_texts(chunks, all_texts, chunk_overlap)
            return TranscriptionResult(text=merged_text, raw=merged_raw)

    def process(
        self, wav_path: Path, duration: float, transcribe_mode: bool, user_prompt: str | None = None
    ) -> dict[str, Any]:
        """
        Process recorded audio: convert if needed, transcribe, optionally transform, save, copy.

        Pipeline:
        1. Transcription: audio -> text via dedicated endpoint (always)
           - With diarization if enabled
           - With auto-chunking if recording exceeds chunk_duration
        2. Transformation: text + prompt -> text via LLM (when prompt is provided)

        Args:
            wav_path: Path to the recorded WAV file.
            duration: Recording duration in seconds.
            transcribe_mode: Whether to use pure transcription mode (step 1 only).
            user_prompt: User prompt to use for transformation (None for transcribe_mode).

        Returns:
            Dict with 'text' (str), 'raw_transcript' (str), 'raw' (dict),
            'duration' (float), 'paths' (dict of Path or None).
        """
        # Resolve parameters
        provider = self.cfg.defaults.provider
        audio_format = self.cfg.defaults.format
        model = self.cfg.defaults.model
        language = self.cfg.defaults.language
        diarize = self.cfg.defaults.diarize
        chunk_duration = self.cfg.defaults.chunk_duration

        base = self._recording_base or wav_path.stem
        keep_transcript = self.save_all or self.cfg.defaults.keep_transcript_files
        copy_to_clip = self.cfg.defaults.copy

        # Resolve user prompt if not provided
        final_user_prompt = None
        if not transcribe_mode:
            if user_prompt is None:
                final_user_prompt = self.cfg.resolve_prompt(self.user_prompt, self.user_prompt_file)
            else:
                final_user_prompt = user_prompt
            self._status("Prompt mode: transcription then transformation.")
        else:
            self._status("Transcribe mode: transcription only, no prompt.")

        logging.debug(f"Applied prompt: {final_user_prompt or 'None (transcribe mode)'}")

        paths: dict[str, Path | None] = {"wav": wav_path}

        # Convert only when the file is not already in a compatible format.
        # Use a dedicated temp directory so conversion output never appears next to the
        # user's source file (important for the `svx process` command).
        to_send_path = wav_path
        if _needs_conversion(wav_path, audio_format):
            self._status("Converting...")
            if self._convert_dir is None:
                self._convert_dir = Path(tempfile.mkdtemp(prefix="svx_convert_"))
            to_send_path = convert_audio(wav_path, audio_format, output_dir=self._convert_dir)
            logging.info("Converted %s -> %s", wav_path, to_send_path)
            paths["converted"] = to_send_path
        else:
            logging.info("Skipping conversion: %s already in compatible format", wav_path.name)

        # Get actual audio duration from file (fall back to wall-clock duration)
        audio_duration = self._get_audio_duration(to_send_path, fallback=duration)

        # Auto-activate save_all for long recordings (uses same duration as chunking)
        self._activate_save_all_for_long_recording(audio_duration)
        # Refresh keep_transcript after potential auto-activation
        keep_transcript = self.save_all or self.cfg.defaults.keep_transcript_files

        # Move compressed file to recordings dir if keeping it
        if audio_format in {"mp3", "opus"} and "converted" in paths:
            keep_compressed = self.save_all or self.cfg.defaults.keep_compressed_audio
            if keep_compressed and paths["converted"] is not None:
                self.cfg.recordings_dir.mkdir(parents=True, exist_ok=True)
                final = self.cfg.recordings_dir / f"{base}.{audio_format}"
                if paths["converted"] != final:
                    shutil.move(str(paths["converted"]), final)
                    to_send_path = final
                    paths["converted"] = final

        # Step 1: Transcription (with optional chunking and diarization)
        if audio_duration > chunk_duration:
            self._status(
                f"Long recording ({audio_duration:.0f}s > {chunk_duration}s): chunking enabled."
            )
            result = self._transcribe_chunked(
                to_send_path, provider, model, language, diarize, base
            )
        else:
            self._status("Transcribing...")
            result = self._transcribe_single(to_send_path, provider, model, language, diarize)

        # Format output text
        raw_transcript: str
        if diarize and "segments" in result and result["segments"]:
            raw_transcript = format_diarized_transcript(result["segments"])
        else:
            raw_transcript = result["text"]

        # Step 2: Transformation (if prompt)
        if not transcribe_mode and final_user_prompt:
            self._status("Applying prompt...")
            chat_model = self.cfg.defaults.chat_model
            prov = get_provider(provider, cfg=self.cfg)
            chat_result = prov.chat(raw_transcript, final_user_prompt, model=chat_model)
            text = chat_result["text"]
            raw: dict[str, Any] = {
                "transcription": result["raw"],
                "transformation": chat_result["raw"],
            }
        else:
            text = raw_transcript
            raw = result["raw"]

        # Save if keeping transcripts
        if keep_transcript:
            self.cfg.transcripts_dir.mkdir(parents=True, exist_ok=True)
            txt_path, json_path = save_transcript(
                self.cfg.transcripts_dir, base, provider, text, raw
            )
            paths["txt"] = txt_path
            paths["json"] = json_path

            # Save raw transcript separately when transformation was applied
            if not transcribe_mode and final_user_prompt:
                raw_txt_path = self.cfg.transcripts_dir / f"{base}_{provider}_raw.txt"
                save_text_file(raw_txt_path, raw_transcript)
                paths["raw_txt"] = raw_txt_path
        else:
            paths["txt"] = None
            paths["json"] = None

        # Copy to clipboard
        if copy_to_clip:
            try:
                copy_to_clipboard(text)
                logging.info("Copied transcription to clipboard")
            except Exception as e:
                logging.warning("Failed to copy to clipboard: %s", e)

        logging.info("Processing finished (%.2fs)", duration)
        return {
            "text": text,
            "raw_transcript": raw_transcript,
            "raw": raw,
            "duration": duration,
            "paths": paths,
        }

    def _get_audio_duration(self, audio_path: Path, fallback: float = 0.0) -> float:
        """Get audio duration in seconds from file metadata, with fallback."""
        try:
            info = sf.info(str(audio_path))
            return info.frames / info.samplerate
        except Exception:
            pass
        try:
            return get_audio_duration(audio_path)
        except Exception:
            logging.warning(
                "Could not read audio duration from %s, using fallback %.1fs",
                audio_path,
                fallback,
            )
            return fallback

    def clean(
        self,
        wav_path: Path,
        paths: dict[str, Path | None],
        keep_raw: bool,
        keep_compressed: bool,
    ) -> None:
        """
        Clean up temporary files.

        Args:
            wav_path: The original WAV path.
            paths: The paths dict from process().
            keep_raw: Whether to keep the raw WAV file.
            keep_compressed: Whether to keep the compressed audio file (mp3/opus).
        """
        if not keep_raw and wav_path.exists():
            wav_path.unlink()
            logging.info("Deleted temp WAV: %s", wav_path)

        if not keep_compressed:
            if "converted" in paths and paths["converted"] and paths["converted"] != wav_path:
                if paths["converted"].exists():
                    paths["converted"].unlink()
                    logging.info("Deleted temp converted: %s", paths["converted"])

        # Clean up chunk temp directory
        if self._chunk_dir and self._chunk_dir.exists():
            shutil.rmtree(self._chunk_dir, ignore_errors=True)
            logging.info("Deleted temp chunk dir: %s", self._chunk_dir)
            self._chunk_dir = None

        # Clean up conversion temp directory (may already be empty if file was moved to recordings)
        if self._convert_dir and self._convert_dir.exists():
            shutil.rmtree(self._convert_dir, ignore_errors=True)
            logging.info("Deleted temp convert dir: %s", self._convert_dir)
            self._convert_dir = None

        self._status("Cleanup completed.")

    def run(self, stop_event: threading.Event | None = None) -> dict[str, Any]:
        """
        Execute the full pipeline.

        Args:
            stop_event: Optional event to signal recording stop (e.g., for GUI).

        Returns:
            Dict with 'text' (str), 'raw_transcript' (str), 'raw' (dict),
            'duration' (float), 'paths' (dict of Path or None).

        Raises:
            Exception: On recording, conversion, or transcription errors.
        """
        self._setup_save_all()

        wav_path, duration = self.record(stop_event)

        if self.transcribe_mode:
            final_user_prompt = None
            self._status("Mode Transcribe activated: no prompt used.")
        else:
            final_user_prompt = self.cfg.resolve_prompt(self.user_prompt, self.user_prompt_file)

        result = self.process(wav_path, duration, self.transcribe_mode, final_user_prompt)

        keep_raw = self.save_all or self.cfg.defaults.keep_raw_audio
        keep_compressed = self.save_all or self.cfg.defaults.keep_compressed_audio
        self.clean(wav_path, result["paths"], keep_raw=keep_raw, keep_compressed=keep_compressed)

        logging.info("Pipeline finished (%.2fs)", duration)
        return result
