"""
ConversionService - Convert arbitrary files to Markdown.

Routing:
- Audio/video files  -> VoxCast's own faster-whisper engine (TranscriptionService)
                        wrapped into Markdown via markdown_format.
- Everything else    -> Microsoft MarkItDown (PDF, DOCX, PPTX, XLSX, CSV, HTML,
                        EPUB, .msg, images, ...).

The service is GUI-agnostic; output-location decisions live in the UI layer.
"""
from __future__ import annotations

import asyncio
import threading
from pathlib import Path
from typing import TYPE_CHECKING

from loguru import logger

from .base_service import BaseService
from .i18n import t
from .markdown_format import TranscriptSegment, build_transcript_markdown

if TYPE_CHECKING:
    from bridge.signals import AppSignals
    from .transcription_service import TranscriptionService

# Audio/video formats routed through VoxCast's Whisper engine.
AUDIO_EXTS: frozenset[str] = frozenset({
    ".mp3", ".ogg", ".opus", ".wav", ".m4a", ".mp4", ".flac",
    ".aac", ".wma", ".webm", ".mkv", ".mov",
})

# Document/image formats routed through MarkItDown (mirrors filetomd scope).
DOCUMENT_EXTS: frozenset[str] = frozenset({
    ".pdf", ".docx", ".pptx", ".xlsx", ".csv", ".json", ".xml", ".html",
    ".htm", ".epub", ".msg", ".txt", ".ipynb", ".zip",
    ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff",
})

# Full set used for context-menu registration and drag-drop acceptance.
SUPPORTED_EXTS: frozenset[str] = AUDIO_EXTS | DOCUMENT_EXTS


class EngineNotReadyError(RuntimeError):
    """The speech engine did not become ready within the wait budget.

    Callers converting a batch should abort the whole batch on this — every
    further file would wait the full budget again for the same reason."""


class ConversionService(BaseService):
    # The speech engine loads for ~5-30s at app start (longer during a model
    # switch). A conversion started in that window waits here instead of
    # producing an empty transcript.
    ENGINE_WAIT_MAX_S = 120.0
    ENGINE_WAIT_POLL_S = 0.5

    def __init__(
        self,
        signals: AppSignals,
        transcription_service: TranscriptionService,
    ) -> None:
        self._signals = signals
        self._transcription = transcription_service
        self._markitdown = None  # lazy
        self._markitdown_lock = threading.Lock()
        self._ready = False

    async def start(self) -> None:
        self._ready = True
        logger.info("ConversionService started")

    async def stop(self) -> None:
        self._ready = False

    @property
    def is_ready(self) -> bool:
        return self._ready

    @staticmethod
    def is_audio(path: Path) -> bool:
        return path.suffix.lower() in AUDIO_EXTS

    @staticmethod
    def is_supported(path: Path) -> bool:
        return path.suffix.lower() in SUPPORTED_EXTS

    async def convert_file(
        self,
        input_path: Path,
        output_path: Path,
        *,
        language: str | None = None,
        with_timestamps: bool = False,
    ) -> Path:
        """Convert ``input_path`` to Markdown at ``output_path``. Returns the
        written path. Raises on unrecoverable errors (caller handles UI feedback).

        ``language=None`` means per-segment auto-detection of the spoken
        language (converted files are routinely not in the dictation
        language); a set language forces it."""
        input_path = Path(input_path)
        output_path = Path(output_path)
        if not input_path.exists() or not input_path.is_file():
            raise FileNotFoundError(f"Input file not found: {input_path}")

        if self.is_audio(input_path):
            markdown = await self._convert_audio(
                input_path, language=language, with_timestamps=with_timestamps,
            )
        else:
            markdown = await self._convert_document(input_path)

        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(markdown, encoding="utf-8")
        logger.info("Converted {} -> {}", input_path.name, output_path)
        return output_path

    async def _convert_audio(
        self, input_path: Path, *, language: str | None, with_timestamps: bool
    ) -> str:
        # transcribe_file returns ("", {}) instead of raising when the engine
        # is not ready — that used to write an empty transcript and report
        # success ("converted, but no usable file until the second attempt").
        # Wait for the engine, then treat an empty result as the error it is.
        waited = 0.0
        while not self._transcription.is_ready and waited < self.ENGINE_WAIT_MAX_S:
            await asyncio.sleep(self.ENGINE_WAIT_POLL_S)
            waited += self.ENGINE_WAIT_POLL_S
        if not self._transcription.is_ready:
            raise EngineNotReadyError(t("err_convert_engine_loading"))

        text, meta = await self._transcription.transcribe_file(
            input_path, language=language, with_timestamps=with_timestamps,
            multilingual=language is None,
        )
        if not text.strip():
            # Empty meta = the engine failed (OOM, decode error, mid-flight
            # model swap) and, where it could, already toasted the specific
            # cause — report a consistent generic failure, never "no speech".
            # Non-empty meta = the file really contains no recognizable
            # speech. Either way: no junk file, no false success.
            if not meta:
                raise RuntimeError(t("err_convert_engine_error"))
            raise ValueError(t("err_convert_no_speech"))
        segments = None
        if with_timestamps and meta.get("segments"):
            segments = [
                TranscriptSegment(start=s["start"], end=s["end"], text=s["text"])
                for s in meta["segments"]
            ]
        return build_transcript_markdown(
            source_name=input_path.name,
            text=text,
            duration=meta.get("duration"),
            language=meta.get("language") or language,
            segments=segments,
        )

    async def _convert_document(self, input_path: Path) -> str:
        """Run MarkItDown in an executor (it is synchronous and CPU-bound)."""
        import asyncio

        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self._markitdown_convert, str(input_path))

    def _markitdown_convert(self, path: str) -> str:
        is_pdf = path.lower().endswith(".pdf")
        markdown = ""
        try:
            result = self._get_markitdown().convert(path)
            markdown = (result.markdown or "").strip()
        except Exception as e:
            # MarkItDown's PDF backend (pdfminer) is fragile and raises/returns
            # empty on many real-world PDFs. For PDFs we retry with PDFium below;
            # for other formats the error is genuine, so re-raise.
            if not is_pdf:
                raise
            logger.warning("MarkItDown failed for {} ({}); trying PDFium fallback", path, e)

        if not markdown and is_pdf:
            markdown = self._extract_pdf_text(path).strip()

        if not markdown:
            if is_pdf:
                raise ValueError(
                    "This PDF has no selectable text — it looks like a scan or "
                    "image-only document. Converting it would require OCR, which "
                    "VoxCast does not support yet."
                )
            raise ValueError("No extractable text content (empty or unreadable file)")
        return markdown + "\n"

    @staticmethod
    def _extract_pdf_text(path: str) -> str:
        """Robust PDF text extraction via PDFium (Chrome's engine). Handles many
        PDFs that pdfminer chokes on. Returns '' if there is no text layer; raises
        a clear error for password-protected files."""
        try:
            import pypdfium2 as pdfium  # bundled (renderer used elsewhere too)
        except Exception as e:
            logger.warning("pypdfium2 unavailable for PDF fallback: {}", e)
            return ""

        try:
            doc = pdfium.PdfDocument(path)
        except pdfium.PdfiumError as e:
            if "password" in str(e).lower():
                raise ValueError(
                    "This PDF is password-protected. Remove the password, then "
                    "convert it again."
                ) from e
            logger.warning("PDFium could not open {}: {}", path, e)
            return ""

        try:
            parts: list[str] = []
            for i in range(len(doc)):
                page = doc[i]
                textpage = page.get_textpage()
                try:
                    parts.append((textpage.get_text_bounded() or "").strip())
                finally:
                    textpage.close()
                    page.close()
            return "\n\n".join(p for p in parts if p)
        finally:
            doc.close()

    def _get_markitdown(self):
        # Thread-safe lazy init: _convert_document runs in an executor thread,
        # so concurrent conversions must not double-initialize.
        if self._markitdown is None:
            with self._markitdown_lock:
                if self._markitdown is None:
                    from markitdown import MarkItDown  # type: ignore[import]
                    # Audio is handled by our own Whisper engine, so MarkItDown's
                    # speech-recognition plugins are intentionally not relied upon.
                    self._markitdown = MarkItDown()
                    logger.debug("MarkItDown initialized")
        return self._markitdown
