from __future__ import annotations

import json
import logging
import os
import shutil
import sys
import threading
import time
import uuid
import traceback
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping

from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config
from abogen.voice_cache import bootstrap_voice_cache
from abogen.integrations.audiobookshelf import (
    AudiobookshelfClient,
    AudiobookshelfConfig,
    AudiobookshelfUploadError,
)
from abogen.domain.metadata_helpers import (
    normalize_metadata_casefold as _normalize_metadata_casefold,
    split_people_field as _split_people_field,
    split_simple_list as _split_simple_list,
    first_nonempty as _first_nonempty,
    extract_year as _extract_year,
    normalize_series_sequence as _normalize_series_sequence,
    build_audiobookshelf_metadata as _build_abs_metadata,
    load_audiobookshelf_chapters as _load_abs_chapters,
    _SERIES_SEQUENCE_TAG_KEYS,
)


def _create_set_event() -> threading.Event:
    event = threading.Event()
    event.set()
    return event


STATE_VERSION = 8


_JOB_LOGGER = logging.getLogger("abogen.jobs")
if not _JOB_LOGGER.handlers:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S"))
    _JOB_LOGGER.addHandler(handler)
    _JOB_LOGGER.propagate = False
_JOB_LOGGER.setLevel(logging.DEBUG)

_JOB_LEVEL_MAP: Dict[str, int] = {
    "critical": logging.CRITICAL,
    "error": logging.ERROR,
    "warning": logging.WARNING,
    "info": logging.INFO,
    "success": logging.INFO,
    "debug": logging.DEBUG,
    "trace": logging.DEBUG,
}


def _emit_job_log(job_id: str, level: str, message: str) -> None:
    normalized = (level or "info").lower()
    log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO)
    try:
        _JOB_LOGGER.log(log_level, "[job %s] %s", job_id, message)
    except Exception:
        # Logging failures should never disrupt job processing, but we should know about them.
        try:
            sys.stderr.write(f"Logging failed for job {job_id}: {message}\n")
            traceback.print_exc(file=sys.stderr)
        except Exception:
            pass


class JobStatus(str, Enum):
    PENDING = "pending"
    RUNNING = "running"
    PAUSED = "paused"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"


@dataclass
class JobLog:
    timestamp: float
    message: str
    level: str = "info"


@dataclass
class JobResult:
    audio_path: Optional[Path] = None
    subtitle_paths: List[Path] = field(default_factory=list)
    artifacts: Dict[str, Path] = field(default_factory=dict)
    epub_path: Optional[Path] = None


@dataclass
class Job:
    id: str
    original_filename: str
    stored_path: Path
    language: str
    voice: str
    speed: float
    use_gpu: bool
    subtitle_mode: str
    output_format: str
    save_mode: str
    output_folder: Optional[Path]
    replace_single_newlines: bool
    subtitle_format: str
    created_at: float
    tts_provider: str = "kokoro"
    supertonic_total_steps: int = 5
    save_chapters_separately: bool = False
    merge_chapters_at_end: bool = True
    separate_chapters_format: str = "wav"
    silence_between_chapters: float = 2.0
    save_as_project: bool = False
    voice_profile: Optional[str] = None
    metadata_tags: Dict[str, str] = field(default_factory=dict)
    max_subtitle_words: int = 50
    chapter_intro_delay: float = 0.5
    read_title_intro: bool = False
    read_closing_outro: bool = True
    auto_prefix_chapter_titles: bool = True
    normalize_chapter_opening_caps: bool = True
    status: JobStatus = JobStatus.PENDING
    started_at: Optional[float] = None
    finished_at: Optional[float] = None
    progress: float = 0.0
    total_characters: int = 0
    processed_characters: int = 0
    etr_str: str = ""
    logs: List[JobLog] = field(default_factory=list)
    error: Optional[str] = None
    result: JobResult = field(default_factory=JobResult)
    chapters: List[Dict[str, Any]] = field(default_factory=list)
    queue_position: Optional[int] = None
    cancel_requested: bool = False
    pause_requested: bool = False
    paused: bool = False
    resume_token: Optional[str] = None
    pause_event: threading.Event = field(default_factory=_create_set_event, repr=False, compare=False)
    cover_image_path: Optional[Path] = None
    cover_image_mime: Optional[str] = None
    chunk_level: str = "paragraph"
    chunks: List[Dict[str, Any]] = field(default_factory=list)
    speakers: Dict[str, Any] = field(default_factory=dict)
    speaker_mode: str = "single"
    generate_epub3: bool = False
    speaker_analysis: Dict[str, Any] = field(default_factory=dict)
    speaker_analysis_threshold: int = 3
    analysis_requested: bool = False
    entity_summary: Dict[str, Any] = field(default_factory=dict)
    manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
    pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
    heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
    normalization_overrides: Dict[str, Any] = field(default_factory=dict)
    speaker_voice_languages: List[str] = field(default_factory=list)
    applied_speaker_config: Optional[str] = None

    @property
    def estimated_time_remaining(self) -> Optional[float]:
        """
        Returns the estimated seconds remaining.
        Uses the same calc_etr_str from domain/progress.py as the PyQt desktop GUI.
        """
        from abogen.domain.progress import calc_etr_str

        if self.status != JobStatus.RUNNING or not self.started_at or self.total_characters <= 0:
            return None

        elapsed = time.time() - self.started_at
        if elapsed <= 0:
            return None

        etr = calc_etr_str(elapsed, self.processed_characters, self.total_characters)
        if etr == "Processing...":
            return None

        # Parse "HH:MM:SS" back to seconds for backward compatibility
        parts = etr.split(":")
        return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])

    def add_log(self, message: str, level: str = "info") -> None:
        entry = JobLog(timestamp=time.time(), message=message, level=level)
        self.logs.append(entry)
        _emit_job_log(self.id, level, message)

    def as_dict(self) -> Dict[str, object]:
        return {
            "id": self.id,
            "original_filename": self.original_filename,
            "status": self.status.value,
            "use_gpu": self.use_gpu,
            "created_at": self.created_at,
            "started_at": self.started_at,
            "finished_at": self.finished_at,
            "progress": self.progress,
            "total_characters": self.total_characters,
            "processed_characters": self.processed_characters,
            "etr_str": self.etr_str,
            "error": self.error,
            "logs": [log.__dict__ for log in self.logs],
            "result": {
                "audio": str(self.result.audio_path) if self.result.audio_path else None,
                "subtitles": [str(path) for path in self.result.subtitle_paths],
                "artifacts": {key: str(path) for key, path in self.result.artifacts.items()},
            },
            "queue_position": self.queue_position,
            "options": {
                "tts_provider": getattr(self, "tts_provider", "kokoro"),
                "supertonic_total_steps": getattr(self, "supertonic_total_steps", 5),
                "save_chapters_separately": self.save_chapters_separately,
                "merge_chapters_at_end": self.merge_chapters_at_end,
                "separate_chapters_format": self.separate_chapters_format,
                "silence_between_chapters": self.silence_between_chapters,
                "save_as_project": self.save_as_project,
                "voice_profile": self.voice_profile,
                "max_subtitle_words": self.max_subtitle_words,
                "chapter_intro_delay": self.chapter_intro_delay,
                "read_title_intro": getattr(self, "read_title_intro", False),
                "read_closing_outro": getattr(self, "read_closing_outro", True),
                "auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True),
                "normalize_chapter_opening_caps": getattr(self, "normalize_chapter_opening_caps", True),
            },
            "metadata_tags": dict(self.metadata_tags),
            "chapters": [
                {
                    "id": entry.get("id"),
                    "index": entry.get("index"),
                    "order": entry.get("order"),
                    "title": entry.get("title"),
                    "enabled": bool(entry.get("enabled", True)),
                    "voice": entry.get("voice"),
                    "voice_profile": entry.get("voice_profile"),
                    "voice_formula": entry.get("voice_formula"),
                    "resolved_voice": entry.get("resolved_voice"),
                    "characters": len(str(entry.get("text", ""))),
                }
                for entry in self.chapters
            ],
            "chunk_level": self.chunk_level,
            "chunks": [dict(chunk) for chunk in self.chunks],
            "speakers": dict(self.speakers),
            "speaker_mode": self.speaker_mode,
            "generate_epub3": self.generate_epub3,
            "speaker_analysis": dict(self.speaker_analysis),
            "speaker_analysis_threshold": self.speaker_analysis_threshold,
            "analysis_requested": self.analysis_requested,
            "speaker_voice_languages": list(self.speaker_voice_languages),
            "applied_speaker_config": self.applied_speaker_config,
            "entity_summary": dict(self.entity_summary),
            "manual_overrides": [dict(entry) for entry in self.manual_overrides],
            "pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides],
            "heteronym_overrides": [dict(entry) for entry in self.heteronym_overrides],
            "normalization_overrides": dict(self.normalization_overrides),
        }


def build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
    filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook"
    return _build_abs_metadata(
        job.metadata_tags,
        language=job.language or "",
        filename=filename,
    )


def load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]:
    metadata_ref = job.result.artifacts.get("metadata")
    if not metadata_ref:
        return None
    metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
    return _load_abs_chapters(metadata_path)


def _existing_paths(paths: Iterable[Any]) -> List[Path]:
    resolved: List[Path] = []
    for item in paths:
        candidate = item if isinstance(item, Path) else Path(str(item))
        if candidate.exists():
            resolved.append(candidate)
    return resolved


@dataclass
class PendingJob:
    id: str
    original_filename: str
    stored_path: Path
    language: str
    voice: str
    speed: float
    use_gpu: bool
    subtitle_mode: str
    output_format: str
    save_mode: str
    output_folder: Optional[Path]
    replace_single_newlines: bool
    subtitle_format: str
    total_characters: int
    save_chapters_separately: bool
    merge_chapters_at_end: bool
    separate_chapters_format: str
    silence_between_chapters: float
    save_as_project: bool
    voice_profile: Optional[str]
    max_subtitle_words: int
    metadata_tags: Dict[str, Any]
    chapters: List[Dict[str, Any]]
    normalization_overrides: Dict[str, Any]
    created_at: float
    tts_provider: str = "kokoro"
    supertonic_total_steps: int = 5
    cover_image_path: Optional[Path] = None
    cover_image_mime: Optional[str] = None
    chapter_intro_delay: float = 0.5
    read_title_intro: bool = False
    read_closing_outro: bool = True
    auto_prefix_chapter_titles: bool = True
    normalize_chapter_opening_caps: bool = True
    chunk_level: str = "paragraph"
    chunks: List[Dict[str, Any]] = field(default_factory=list)
    speakers: Dict[str, Any] = field(default_factory=dict)
    speaker_mode: str = "single"
    generate_epub3: bool = False
    speaker_analysis: Dict[str, Any] = field(default_factory=dict)
    speaker_analysis_threshold: int = 3
    analysis_requested: bool = False
    speaker_voice_languages: List[str] = field(default_factory=list)
    applied_speaker_config: Optional[str] = None
    entity_summary: Dict[str, Any] = field(default_factory=dict)
    manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
    pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
    heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
    entity_cache_key: Optional[str] = None
    wizard_max_step_index: int = 0


class ConversionService:
    def __init__(
        self,
        output_root: Path,
        runner: Callable[[Job], None],
        *,
        uploads_root: Optional[Path] = None,
        poll_interval: float = 0.5,
    ) -> None:
        self._jobs: Dict[str, Job] = {}
        self._queue: List[str] = []
        self._lock = threading.RLock()
        self._worker_thread: Optional[threading.Thread] = None
        self._stop_event = threading.Event()
        self._wake_event = threading.Event()
        self._output_root = output_root
        self._uploads_root = uploads_root or output_root / "uploads"
        self._runner = runner
        self._poll_interval = poll_interval
        self._pending_jobs: Dict[str, PendingJob] = {}
        self._state_path = self._determine_state_path()
        self._ensure_directories()
        self._bootstrap_voice_cache()
        self._load_state()

    # Public API ---------------------------------------------------------
    def list_jobs(self) -> List[Job]:
        with self._lock:
            return sorted(self._jobs.values(), key=lambda job: job.created_at, reverse=True)

    def get_job(self, job_id: str) -> Optional[Job]:
        with self._lock:
            return self._jobs.get(job_id)

    def enqueue(
        self,
        *,
        original_filename: str,
        stored_path: Path,
        language: str,
        voice: str,
        speed: float,
        tts_provider: str = "kokoro",
        supertonic_total_steps: int = 5,
        use_gpu: bool,
        subtitle_mode: str,
        output_format: str,
        save_mode: str,
        output_folder: Optional[Path],
        replace_single_newlines: bool,
        subtitle_format: str,
        total_characters: int,
        chapters: Optional[Iterable[Any]] = None,
        save_chapters_separately: bool = False,
        merge_chapters_at_end: bool = True,
        separate_chapters_format: str = "wav",
        silence_between_chapters: float = 2.0,
        save_as_project: bool = False,
        voice_profile: Optional[str] = None,
        max_subtitle_words: int = 50,
        metadata_tags: Optional[Mapping[str, Any]] = None,
        cover_image_path: Optional[Path] = None,
            cover_image_mime: Optional[str] = None,
            chapter_intro_delay: float = 0.5,
            read_title_intro: bool = False,
            read_closing_outro: bool = True,
            auto_prefix_chapter_titles: bool = True,
            normalize_chapter_opening_caps: bool = True,
        chunk_level: str = "paragraph",
        chunks: Optional[Iterable[Any]] = None,
        speakers: Optional[Mapping[str, Any]] = None,
        speaker_mode: str = "single",
        generate_epub3: bool = False,
        speaker_analysis: Optional[Mapping[str, Any]] = None,
        speaker_analysis_threshold: int = 3,
        analysis_requested: bool = False,
        entity_summary: Optional[Mapping[str, Any]] = None,
        manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
        pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
        heteronym_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
        normalization_overrides: Optional[Mapping[str, Any]] = None,
    ) -> Job:
        job_id = uuid.uuid4().hex
        normalized_metadata = self._normalize_metadata_tags(metadata_tags)
        normalized_chapters = self._normalize_chapters(chapters)
        normalized_chunks = self._normalize_chunks(chunks)
        if total_characters <= 0 and normalized_chapters:
            total_characters = sum(len(str(entry.get("text", ""))) for entry in normalized_chapters)
        job = Job(
            id=job_id,
            original_filename=original_filename,
            stored_path=stored_path,
            language=language,
            voice=voice,
            speed=speed,
            tts_provider=tts_provider,
            supertonic_total_steps=int(supertonic_total_steps or 5),
            use_gpu=use_gpu,
            subtitle_mode=subtitle_mode,
            output_format=output_format,
            save_mode=save_mode,
            output_folder=output_folder,
            replace_single_newlines=replace_single_newlines,
            subtitle_format=subtitle_format,
            save_chapters_separately=save_chapters_separately,
            merge_chapters_at_end=merge_chapters_at_end,
            separate_chapters_format=separate_chapters_format,
            silence_between_chapters=silence_between_chapters,
            save_as_project=save_as_project,
            voice_profile=voice_profile,
            max_subtitle_words=max_subtitle_words,
            metadata_tags=normalized_metadata,
            created_at=time.time(),
            total_characters=total_characters,
            chapters=normalized_chapters,
            cover_image_path=cover_image_path,
            cover_image_mime=cover_image_mime,
            chapter_intro_delay=chapter_intro_delay,
            read_title_intro=bool(read_title_intro),
            read_closing_outro=bool(read_closing_outro),
            auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
            normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
            chunk_level=chunk_level,
            chunks=normalized_chunks,
            speakers=dict(speakers or {}),
            speaker_mode=speaker_mode,
            generate_epub3=bool(generate_epub3),
            speaker_analysis=dict(speaker_analysis or {}),
            speaker_analysis_threshold=int(speaker_analysis_threshold or 3),
            analysis_requested=bool(analysis_requested),
            entity_summary=dict(entity_summary or {}),
            manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [],
            pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [],
            heteronym_overrides=[dict(entry) for entry in heteronym_overrides] if heteronym_overrides else [],
            normalization_overrides=dict(normalization_overrides or {}),
        )
        with self._lock:
            self._jobs[job_id] = job
            self._queue.append(job_id)
            self._update_queue_positions_locked()
            self._wake_event.set()
        self._ensure_worker()
        job.add_log("Job queued")
        return job

    def store_pending_job(self, pending: PendingJob) -> None:
        with self._lock:
            self._pending_jobs[pending.id] = pending

    def get_pending_job(self, pending_id: str) -> Optional[PendingJob]:
        with self._lock:
            return self._pending_jobs.get(pending_id)

    def pop_pending_job(self, pending_id: str) -> Optional[PendingJob]:
        with self._lock:
            return self._pending_jobs.pop(pending_id, None)

    def cancel(self, job_id: str) -> bool:
        with self._lock:
            job = self._jobs.get(job_id)
            if job is None:
                return False
            if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
                return False
            job.cancel_requested = True
            job.pause_requested = False
            job.paused = False
            job.add_log("Cancellation requested", level="warning")
            job.pause_event.set()
            if job.status == JobStatus.PENDING:
                job.status = JobStatus.CANCELLED
                self._queue.remove(job_id)
                job.finished_at = time.time()
                self._update_queue_positions_locked()
            self._persist_state()
            return True

    def pause(self, job_id: str) -> bool:
        with self._lock:
            job = self._jobs.get(job_id)
            if job is None:
                return False
            if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
                return False
            if job.pause_requested or job.paused:
                return True

            job.pause_requested = True
            job.add_log("Pause requested; finishing current chunk before stopping.", level="warning")

            if job.status == JobStatus.PENDING:
                if job_id in self._queue:
                    self._queue.remove(job_id)
                    self._update_queue_positions_locked()
                job.status = JobStatus.PAUSED
                job.paused = True
                job.pause_event.clear()
            self._persist_state()
            return True

    def resume(self, job_id: str) -> bool:
        with self._lock:
            job = self._jobs.get(job_id)
            if job is None:
                return False
            if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
                return False

            job.pause_requested = False

            if job.status == JobStatus.PAUSED and job.started_at is None:
                job.status = JobStatus.PENDING
                job.paused = False
                job.pause_event.set()
                if job_id not in self._queue:
                    self._queue.insert(0, job_id)
                self._update_queue_positions_locked()
                self._wake_event.set()
                job.add_log("Resume requested; returning job to queue.", level="info")
            else:
                job.paused = False
                job.pause_event.set()
                if job.status == JobStatus.PAUSED:
                    job.status = JobStatus.RUNNING
                job.add_log("Resume requested", level="info")

            self._persist_state()
            return True

    def retry(self, job_id: str) -> Optional[Job]:
        with self._lock:
            job = self._jobs.get(job_id)
            if job is None:
                return None
            if job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
                job.add_log(
                    "Retry requested while job still active; ignoring.",
                    level="warning",
                )
                return None

            stored_path = job.stored_path
            if not isinstance(stored_path, Path):
                stored_path = Path(str(stored_path))

            if not stored_path.exists():
                job.add_log(
                    f"Retry requested but source file is missing: {stored_path}",
                    level="error",
                )
                return None

            new_job = self.enqueue(
                original_filename=job.original_filename,
                stored_path=stored_path,
                language=job.language,
                voice=job.voice,
                speed=job.speed,
                use_gpu=job.use_gpu,
                subtitle_mode=job.subtitle_mode,
                output_format=job.output_format,
                save_mode=job.save_mode,
                output_folder=job.output_folder,
                replace_single_newlines=job.replace_single_newlines,
                subtitle_format=job.subtitle_format,
                total_characters=job.total_characters,
                chapters=job.chapters,
                save_chapters_separately=job.save_chapters_separately,
                merge_chapters_at_end=job.merge_chapters_at_end,
                separate_chapters_format=job.separate_chapters_format,
                silence_between_chapters=job.silence_between_chapters,
                save_as_project=job.save_as_project,
                voice_profile=job.voice_profile,
                max_subtitle_words=job.max_subtitle_words,
                metadata_tags=job.metadata_tags,
                cover_image_path=job.cover_image_path,
                cover_image_mime=job.cover_image_mime,
                chapter_intro_delay=job.chapter_intro_delay,
                read_title_intro=job.read_title_intro,
                auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
                normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
                chunk_level=job.chunk_level,
                chunks=job.chunks,
                speakers=job.speakers,
                speaker_mode=job.speaker_mode,
                generate_epub3=job.generate_epub3,
                speaker_analysis=job.speaker_analysis,
                speaker_analysis_threshold=job.speaker_analysis_threshold,
                analysis_requested=job.analysis_requested,
                entity_summary=job.entity_summary,
                manual_overrides=job.manual_overrides,
                pronunciation_overrides=job.pronunciation_overrides,
                normalization_overrides=job.normalization_overrides,
            )

            new_job.speaker_voice_languages = list(job.speaker_voice_languages)
            new_job.applied_speaker_config = job.applied_speaker_config
            new_job.add_log(f"Retry created from job {job.id}", level="info")
            job.add_log(f"Retry scheduled as job {new_job.id}", level="info")
            self._remove_job_locked(job_id)
            return new_job

    def delete(self, job_id: str) -> bool:
        with self._lock:
            job = self._jobs.get(job_id)
            if job is None:
                return False
            if job.status in {JobStatus.RUNNING}:
                return False
            self._jobs.pop(job_id)
            if job_id in self._queue:
                self._queue.remove(job_id)
                self._update_queue_positions_locked()
            self._persist_state()
            return True

    def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int:
        finished_statuses = set(statuses or {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED})
        removed = 0
        with self._lock:
            # Remove any queued entries first to avoid stale references
            filtered_queue: List[str] = []
            for job_id in self._queue:
                job = self._jobs.get(job_id)
                if job and job.status in finished_statuses:
                    continue
                filtered_queue.append(job_id)
            self._queue = filtered_queue

            for job_id, job in list(self._jobs.items()):
                if job.status in finished_statuses:
                    self._jobs.pop(job_id)
                    removed += 1

            if removed:
                self._update_queue_positions_locked()
            self._persist_state()
        return removed

    def shutdown(self) -> None:
        self._stop_event.set()
        self._wake_event.set()
        if self._worker_thread and self._worker_thread.is_alive():
            self._worker_thread.join(timeout=5)
            self._worker_thread = None

    # Internal -----------------------------------------------------------
    def _ensure_directories(self) -> None:
        self._output_root.mkdir(parents=True, exist_ok=True)
        self._uploads_root.mkdir(parents=True, exist_ok=True)
        self._state_path.parent.mkdir(parents=True, exist_ok=True)

    def _bootstrap_voice_cache(self) -> None:
        try:
            downloaded, errors = bootstrap_voice_cache(
                on_progress=lambda msg: _JOB_LOGGER.debug("[voice cache] %s", msg)
            )
        except RuntimeError as exc:
            _JOB_LOGGER.warning("Voice cache bootstrap skipped: %s", exc)
            return

        if downloaded:
            count = len(downloaded)
            suffix = "s" if count != 1 else ""
            _JOB_LOGGER.info("Voice cache ready: downloaded %d new asset%s.", count, suffix)
        if errors:
            for voice_id, message in errors.items():
                _JOB_LOGGER.warning("Voice cache failed for %s: %s", voice_id, message)

    def _ensure_worker(self) -> None:
        with self._lock:
            if self._worker_thread and self._worker_thread.is_alive():
                return
            self._stop_event.clear()
            self._worker_thread = threading.Thread(
                target=self._worker_loop,
                name="abogen-conversion-worker",
                daemon=True,
            )
            self._worker_thread.start()

    def _worker_loop(self) -> None:
        while not self._stop_event.is_set():
            job = None
            with self._lock:
                self._wake_event.clear()
                while self._queue and self._jobs[self._queue[0]].status in {
                    JobStatus.CANCELLED,
                    JobStatus.COMPLETED,
                    JobStatus.FAILED,
                }:
                    self._queue.pop(0)
                if self._queue:
                    job = self._jobs[self._queue.pop(0)]
                else:
                    self._update_queue_positions_locked()
            if job is None:
                self._wake_event.wait(timeout=self._poll_interval)
                continue
            if job.cancel_requested:
                job.add_log("Job cancelled before start", level="warning")
                job.status = JobStatus.CANCELLED
                job.finished_at = time.time()
                continue
            self._run_job(job)

    def _run_job(self, job: Job) -> None:
        job.pause_event.set()
        job.pause_requested = False
        job.paused = False
        job.status = JobStatus.RUNNING
        job.started_at = time.time()
        job.add_log("Job started", level="info")
        self._persist_state()
        try:
            self._runner(job)
        except Exception as exc:  # pragma: no cover - defensive
            job.error = str(exc)
            job.status = JobStatus.FAILED
            job.finished_at = time.time()
            exc_type = exc.__class__.__name__
            job.add_log(f"Job failed ({exc_type}): {exc}", level="error")
            tb_lines = traceback.format_exception(exc.__class__, exc, exc.__traceback__)
            for line in tb_lines[:20]:
                trimmed = line.rstrip()
                if trimmed:
                    for snippet in trimmed.splitlines():
                        job.add_log(f"TRACE: {snippet}", level="debug")
        else:
            if job.cancel_requested:
                job.status = JobStatus.CANCELLED
                job.add_log("Job cancelled", level="warning")
            elif job.status != JobStatus.FAILED:
                job.status = JobStatus.COMPLETED
                job.add_log("Job completed", level="success")
                self._post_completion_hooks(job)
            job.finished_at = time.time()
        finally:
            job.pause_event.set()
            self._persist_state()
            with self._lock:
                self._update_queue_positions_locked()

    def _update_queue_positions_locked(self) -> None:
        for index, job_id in enumerate(self._queue, start=1):
            job = self._jobs.get(job_id)
            if job:
                job.queue_position = index
        self._persist_state()

    def _remove_job_locked(self, job_id: str) -> None:
        self._jobs.pop(job_id, None)
        if job_id in self._queue:
            self._queue.remove(job_id)
        self._update_queue_positions_locked()

    def _post_completion_hooks(self, job: Job) -> None:
        try:
            self._maybe_send_to_audiobookshelf(job)
        except AudiobookshelfUploadError as exc:
            job.add_log(f"Audiobookshelf upload failed: {exc}", level="error")
        except Exception as exc:  # pragma: no cover - defensive guard
            job.add_log(f"Audiobookshelf integration error: {exc}", level="error")

    def _maybe_send_to_audiobookshelf(self, job: Job) -> None:
        cfg = load_config() or {}
        integration_cfg = cfg.get("audiobookshelf")
        if not isinstance(integration_cfg, Mapping):
            return
        enabled = self._coerce_bool(integration_cfg.get("enabled"), False)
        auto_send = self._coerce_bool(integration_cfg.get("auto_send"), False)
        if not (enabled and auto_send):
            return

        base_url = str(integration_cfg.get("base_url") or "").strip()
        api_token = str(integration_cfg.get("api_token") or "").strip()
        library_id = str(integration_cfg.get("library_id") or "").strip()
        folder_id = str(integration_cfg.get("folder_id") or "").strip()
        if not base_url or not api_token or not library_id:
            job.add_log(
                "Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
                level="warning",
            )
            return
        if not folder_id:
            job.add_log(
                "Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.",
                level="warning",
            )
            return

        audio_ref = job.result.audio_path
        audio_path = audio_ref if isinstance(audio_ref, Path) else Path(str(audio_ref)) if audio_ref else None
        if not audio_path or not audio_path.exists():
            job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning")
            return

        timeout_raw = integration_cfg.get("timeout", 3600.0)
        try:
            timeout_value = float(timeout_raw)
        except (TypeError, ValueError):
            timeout_value = 3600.0

        config = AudiobookshelfConfig(
            base_url=base_url,
            api_token=api_token,
            library_id=library_id,
            collection_id=(str(integration_cfg.get("collection_id") or "").strip() or None),
            folder_id=folder_id,
            verify_ssl=self._coerce_bool(integration_cfg.get("verify_ssl"), True),
            send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True),
            send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True),
            send_subtitles=self._coerce_bool(integration_cfg.get("send_subtitles"), False),
            timeout=timeout_value,
        )

        cover_ref = job.cover_image_path
        cover_path = None
        if config.send_cover and cover_ref:
            cover_candidate = cover_ref if isinstance(cover_ref, Path) else Path(str(cover_ref))
            if cover_candidate.exists():
                cover_path = cover_candidate

        subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
        chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None
        metadata = build_audiobookshelf_metadata(job)

        client = AudiobookshelfClient(config)

        display_title = metadata.get("title") or audio_path.stem
        try:
            existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
        except AudiobookshelfUploadError as exc:
            job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
            return

        if existing_items:
            job.add_log(
                f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.",
                level="info",
            )
            try:
                client.delete_items(existing_items)
            except Exception as exc:
                job.add_log(f"Failed to remove existing item(s): {exc}", level="warning")

        client.upload_audiobook(
            audio_path,
            metadata=metadata,
            cover_path=cover_path,
            chapters=chapters,
            subtitles=subtitles,
        )
        job.add_log("Audiobookshelf upload queued.", level="info")

    # Persistence ------------------------------------------------------
    def _serialize_job(self, job: Job) -> Dict[str, Any]:
        result_audio = str(job.result.audio_path) if job.result.audio_path else None
        result_subtitles = [str(path) for path in job.result.subtitle_paths]
        result_artifacts = {key: str(path) for key, path in job.result.artifacts.items()}
        result_epub = str(job.result.epub_path) if job.result.epub_path else None
        return {
            "id": job.id,
            "original_filename": job.original_filename,
            "stored_path": str(job.stored_path),
            "language": job.language,
            "tts_provider": getattr(job, "tts_provider", "kokoro"),
            "voice": job.voice,
            "speed": job.speed,
            "supertonic_total_steps": getattr(job, "supertonic_total_steps", 5),
            "use_gpu": job.use_gpu,
            "subtitle_mode": job.subtitle_mode,
            "output_format": job.output_format,
            "save_mode": job.save_mode,
            "output_folder": str(job.output_folder) if job.output_folder else None,
            "replace_single_newlines": job.replace_single_newlines,
            "subtitle_format": job.subtitle_format,
            "created_at": job.created_at,
            "save_chapters_separately": job.save_chapters_separately,
            "merge_chapters_at_end": job.merge_chapters_at_end,
            "separate_chapters_format": job.separate_chapters_format,
            "silence_between_chapters": job.silence_between_chapters,
            "save_as_project": job.save_as_project,
            "voice_profile": job.voice_profile,
            "metadata_tags": job.metadata_tags,
            "max_subtitle_words": job.max_subtitle_words,
            "status": job.status.value,
            "started_at": job.started_at,
            "finished_at": job.finished_at,
            "progress": job.progress,
            "total_characters": job.total_characters,
            "processed_characters": job.processed_characters,
            "error": job.error,
            "logs": [log.__dict__ for log in job.logs][-500:],
            "result": {
                "audio_path": result_audio,
                "subtitle_paths": result_subtitles,
                "artifacts": result_artifacts,
                "epub_path": result_epub,
            },
            "chapters": [dict(entry) for entry in job.chapters],
            "queue_position": job.queue_position,
            "cancel_requested": job.cancel_requested,
            "pause_requested": job.pause_requested,
            "paused": job.paused,
            "resume_token": job.resume_token,
            "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None,
            "cover_image_mime": job.cover_image_mime,
            "chapter_intro_delay": job.chapter_intro_delay,
            "read_title_intro": job.read_title_intro,
            "auto_prefix_chapter_titles": job.auto_prefix_chapter_titles,
            "normalize_chapter_opening_caps": job.normalize_chapter_opening_caps,
            "chunk_level": job.chunk_level,
            "chunks": [dict(entry) for entry in job.chunks],
            "speakers": dict(job.speakers),
            "speaker_mode": job.speaker_mode,
            "generate_epub3": job.generate_epub3,
            "speaker_analysis": dict(job.speaker_analysis),
            "speaker_analysis_threshold": job.speaker_analysis_threshold,
            "analysis_requested": job.analysis_requested,
            "entity_summary": dict(job.entity_summary),
            "manual_overrides": [dict(entry) for entry in job.manual_overrides],
            "pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides],
            "heteronym_overrides": [dict(entry) for entry in job.heteronym_overrides],
            "normalization_overrides": dict(job.normalization_overrides),
        }

    def _persist_state(self) -> None:
        try:
            with self._lock:
                snapshot = {
                    "version": STATE_VERSION,
                    "jobs": [self._serialize_job(job) for job in self._jobs.values()],
                    "queue": list(self._queue),
                }
            tmp_path = self._state_path.with_suffix(".tmp")
            with tmp_path.open("w", encoding="utf-8") as handle:
                json.dump(snapshot, handle, indent=2)
            os.replace(tmp_path, self._state_path)
        except Exception:
            # Persistence failures should not disrupt runtime; ignore.
            pass

    def _determine_state_path(self) -> Path:
        override_file = os.environ.get("ABOGEN_QUEUE_STATE_PATH")
        if override_file:
            target_path = Path(override_file).expanduser()
            target_path.parent.mkdir(parents=True, exist_ok=True)
            return target_path

        override_dir = os.environ.get("ABOGEN_QUEUE_STATE_DIR")
        if override_dir:
            base_dir = Path(override_dir).expanduser()
        else:
            settings_override = os.environ.get("ABOGEN_SETTINGS_DIR")
            if settings_override:
                base_dir = Path(settings_override).expanduser() / "queue"
            else:
                try:
                    base_dir = Path(get_user_settings_dir()) / "queue"
                except ModuleNotFoundError:
                    base_dir = Path(get_internal_cache_path("jobs"))
        base_dir.mkdir(parents=True, exist_ok=True)
        target_path = base_dir / "queue_state.json"

        legacy_path = Path(get_internal_cache_path("jobs")) / "queue_state.json"
        if legacy_path.exists() and not target_path.exists():
            try:
                shutil.move(str(legacy_path), target_path)
            except Exception:
                try:
                    shutil.copy2(str(legacy_path), target_path)
                except Exception:
                    pass

        return target_path

    def _deserialize_job(self, payload: Dict[str, Any]) -> Job:
        stored_path = Path(payload["stored_path"])
        output_folder_raw = payload.get("output_folder")
        output_folder = Path(output_folder_raw) if output_folder_raw else None
        job = Job(
            id=payload["id"],
            original_filename=payload["original_filename"],
            stored_path=stored_path,
            language=payload.get("language", "a"),
            tts_provider=str(payload.get("tts_provider") or "kokoro"),
            voice=payload.get("voice", ""),
            speed=float(payload.get("speed", 1.0)),
            use_gpu=bool(payload.get("use_gpu", True)),
            subtitle_mode=payload.get("subtitle_mode", "Disabled"),
            output_format=payload.get("output_format", "wav"),
            save_mode=payload.get("save_mode", "Save next to input file"),
            output_folder=output_folder,
            replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
            subtitle_format=payload.get("subtitle_format", "srt"),
            created_at=float(payload.get("created_at", time.time())),
            supertonic_total_steps=int(payload.get("supertonic_total_steps", 5)),
            save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
            merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
            separate_chapters_format=payload.get("separate_chapters_format", "wav"),
            silence_between_chapters=float(payload.get("silence_between_chapters", 2.0)),
            save_as_project=bool(payload.get("save_as_project", False)),
            voice_profile=payload.get("voice_profile"),
            metadata_tags=payload.get("metadata_tags", {}),
            max_subtitle_words=int(payload.get("max_subtitle_words", 50)),
            chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)),
            read_title_intro=bool(payload.get("read_title_intro", False)),
            auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)),
            normalize_chapter_opening_caps=bool(payload.get("normalize_chapter_opening_caps", True)),
        )
        job.status = JobStatus(payload.get("status", job.status.value))
        job.started_at = payload.get("started_at")
        job.finished_at = payload.get("finished_at")
        job.progress = float(payload.get("progress", 0.0))
        job.total_characters = int(payload.get("total_characters", 0))
        job.processed_characters = int(payload.get("processed_characters", 0))
        job.error = payload.get("error")
        job.logs = [JobLog(**entry) for entry in payload.get("logs", [])]
        result_payload = payload.get("result", {})
        audio_path_raw = result_payload.get("audio_path")
        job.result.audio_path = Path(audio_path_raw) if audio_path_raw else None
        job.result.subtitle_paths = [Path(item) for item in result_payload.get("subtitle_paths", [])]
        job.result.artifacts = {
            key: Path(value) for key, value in result_payload.get("artifacts", {}).items()
        }
        epub_path_raw = result_payload.get("epub_path")
        job.result.epub_path = Path(epub_path_raw) if epub_path_raw else None
        job.chapters = payload.get("chapters", [])
        job.queue_position = payload.get("queue_position")
        job.cancel_requested = bool(payload.get("cancel_requested", False))
        job.pause_requested = bool(payload.get("pause_requested", False))
        job.paused = bool(payload.get("paused", False))
        job.resume_token = payload.get("resume_token")
        cover_path_raw = payload.get("cover_image_path")
        job.cover_image_path = Path(cover_path_raw) if cover_path_raw else None
        job.cover_image_mime = payload.get("cover_image_mime")
        job.chunk_level = str(payload.get("chunk_level", job.chunk_level or "paragraph"))
        job.chunks = self._normalize_chunks(payload.get("chunks"))
        job.speakers = dict(payload.get("speakers", {}))
        job.speaker_mode = str(payload.get("speaker_mode", job.speaker_mode or "single"))
        job.generate_epub3 = bool(payload.get("generate_epub3", job.generate_epub3))
        job.speaker_analysis = payload.get("speaker_analysis", {})
        job.speaker_analysis_threshold = int(
            payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3)
        )
        job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested))
        job.entity_summary = payload.get("entity_summary", {})
        job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)]
        job.pronunciation_overrides = [
            dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
        ]
        job.heteronym_overrides = [
            dict(entry) for entry in payload.get("heteronym_overrides", []) if isinstance(entry, Mapping)
        ]
        job.normalization_overrides = dict(payload.get("normalization_overrides", {}) or {})
        job.pause_event.set()
        return job

    def _load_state(self) -> None:
        if not self._state_path.exists():
            return
        try:
            with self._state_path.open("r", encoding="utf-8") as handle:
                payload = json.load(handle)
        except Exception:
            return

        version = int(payload.get("version", 0) or 0)
        if version not in {STATE_VERSION, STATE_VERSION - 1}:
            return

        jobs_payload = payload.get("jobs", [])
        queue_payload = payload.get("queue", [])
        loaded_jobs: Dict[str, Job] = {}
        requeue: List[str] = []

        for entry in jobs_payload:
            try:
                job = self._deserialize_job(entry)
            except Exception:
                continue

            if job.status in {JobStatus.RUNNING, JobStatus.PAUSED}:
                job.status = JobStatus.PENDING
                job.add_log("Job restored after restart: resetting to pending queue.", level="warning")
                job.progress = 0.0
                job.processed_characters = 0
                job.pause_requested = False
                job.paused = False
                job.pause_event.set()
                requeue.append(job.id)
            elif job.status == JobStatus.PENDING:
                requeue.append(job.id)

            loaded_jobs[job.id] = job

        with self._lock:
            self._jobs = loaded_jobs
            self._queue = [job_id for job_id in queue_payload if job_id in loaded_jobs]
            for job_id in requeue:
                if job_id not in self._queue:
                    self._queue.append(job_id)
            self._update_queue_positions_locked()

        if self._queue:
            self._ensure_worker()

    @staticmethod
    def _coerce_bool(value: Any, default: bool = True) -> bool:
        if isinstance(value, bool):
            return value
        if isinstance(value, str):
            lowered = value.strip().lower()
            if lowered in {"true", "1", "yes", "on"}:
                return True
            if lowered in {"false", "0", "no", "off"}:
                return False
            return default
        if value is None:
            return default
        return bool(value)

    @staticmethod
    def _coerce_optional_int(value: Any) -> Optional[int]:
        if value is None:
            return None
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    @staticmethod
    def _normalize_metadata_tags(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
        if not values:
            return {}
        normalized: Dict[str, str] = {}
        for key, raw_value in values.items():
            if raw_value is None:
                continue
            key_str = str(key).strip()
            if not key_str:
                continue
            normalized[key_str] = str(raw_value)
        return normalized

    @classmethod
    def _normalize_chapters(cls, chapters: Optional[Iterable[Any]]) -> List[Dict[str, Any]]:
        if not chapters:
            return []

        normalized: List[Dict[str, Any]] = []
        for order, raw in enumerate(chapters):
            if raw is None:
                continue

            if isinstance(raw, str):
                raw_dict: Dict[str, Any] = {"title": raw}
            elif isinstance(raw, dict):
                raw_dict = dict(raw)
            else:
                continue

            entry: Dict[str, Any] = {}

            id_value = raw_dict.get("id") or raw_dict.get("chapter_id") or raw_dict.get("key")
            if id_value is not None:
                entry["id"] = str(id_value)

            index_value = (
                cls._coerce_optional_int(raw_dict.get("index"))
                or cls._coerce_optional_int(raw_dict.get("original_index"))
                or cls._coerce_optional_int(raw_dict.get("source_index"))
                or cls._coerce_optional_int(raw_dict.get("chapter_index"))
            )
            if index_value is not None:
                entry["index"] = index_value

            order_value = (
                cls._coerce_optional_int(raw_dict.get("order"))
                or cls._coerce_optional_int(raw_dict.get("position"))
                or cls._coerce_optional_int(raw_dict.get("sort"))
                or cls._coerce_optional_int(raw_dict.get("sort_order"))
            )
            entry["order"] = order_value if order_value is not None else order

            source_title = (
                raw_dict.get("source_title")
                or raw_dict.get("original_title")
                or raw_dict.get("base_title")
            )
            if source_title:
                entry["source_title"] = str(source_title)

            title_value = (
                raw_dict.get("title")
                or raw_dict.get("name")
                or raw_dict.get("label")
                or raw_dict.get("chapter")
            )
            if title_value is not None:
                entry["title"] = str(title_value)
            elif source_title:
                entry["title"] = str(source_title)
            else:
                entry["title"] = f"Chapter {order + 1}"

            text_value = raw_dict.get("text")
            if text_value is None:
                text_value = raw_dict.get("content") or raw_dict.get("body") or raw_dict.get("value")
            if text_value is not None:
                entry["text"] = str(text_value)

            enabled = cls._coerce_bool(
                raw_dict.get("enabled", raw_dict.get("include", raw_dict.get("selected", True))),
                True,
            )
            if "disabled" in raw_dict and cls._coerce_bool(raw_dict.get("disabled"), False):
                enabled = False
            entry["enabled"] = enabled

            metadata_payload = raw_dict.get("metadata") or raw_dict.get("metadata_tags")
            normalized_metadata = cls._normalize_metadata_tags(metadata_payload)
            if normalized_metadata:
                entry["metadata"] = normalized_metadata

            voice_value = raw_dict.get("voice")
            if voice_value:
                entry["voice"] = str(voice_value)

            profile_value = raw_dict.get("voice_profile")
            if profile_value:
                entry["voice_profile"] = str(profile_value)

            formula_value = raw_dict.get("voice_formula") or raw_dict.get("formula")
            if formula_value:
                entry["voice_formula"] = str(formula_value)

            resolved_value = raw_dict.get("resolved_voice")
            if resolved_value:
                entry["resolved_voice"] = str(resolved_value)

            if "characters" in raw_dict:
                try:
                    entry["characters"] = int(raw_dict.get("characters", 0))
                except (TypeError, ValueError):
                    entry["characters"] = len(str(entry.get("text", "")))
            else:
                entry["characters"] = len(str(entry.get("text", "")))

            normalized.append(entry)

        return normalized

    @classmethod
    def _normalize_chunks(cls, chunks: Optional[Iterable[Any]]) -> List[Dict[str, Any]]:
        if not chunks:
            return []

        normalized: List[Dict[str, Any]] = []
        for order, raw in enumerate(chunks):
            if raw is None:
                continue
            if isinstance(raw, dict):
                entry = dict(raw)
            else:
                continue

            chunk: Dict[str, Any] = {}

            identifier = entry.get("id") or entry.get("chunk_id")
            if identifier is not None:
                chunk["id"] = str(identifier)

            try:
                chunk_index = int(entry.get("chunk_index", order))
            except (TypeError, ValueError):
                chunk_index = order
            chunk["chunk_index"] = chunk_index

            try:
                chapter_index = int(entry.get("chapter_index", 0))
            except (TypeError, ValueError):
                chapter_index = 0
            chunk["chapter_index"] = chapter_index

            level_raw = str(entry.get("level", "paragraph")).lower()
            if level_raw not in {"paragraph", "sentence"}:
                level_raw = "paragraph"
            chunk["level"] = level_raw

            text_value = entry.get("text")
            if text_value is not None:
                chunk["text"] = str(text_value)
            else:
                chunk["text"] = ""

            normalized_value = entry.get("normalized_text")
            if normalized_value is not None:
                chunk["normalized_text"] = str(normalized_value)

            for text_key in ("display_text", "original_text"):
                if text_key in entry and entry[text_key] is not None:
                    chunk[text_key] = str(entry[text_key])

            speaker_value = entry.get("speaker_id", entry.get("speaker"))
            chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator"

            for key in ("voice", "voice_profile", "voice_formula", "audio_path", "start", "end"):
                if key in entry and entry[key] is not None:
                    chunk[key] = entry[key]

            normalized.append(chunk)
        return normalized


def default_storage_root() -> Path:
    base = Path.cwd()
    uploads = base / "var" / "uploads"
    outputs = base / "var" / "outputs"
    outputs.mkdir(parents=True, exist_ok=True)
    uploads.mkdir(parents=True, exist_ok=True)
    return outputs


def build_service(
    runner: Callable[[Job], None],
    *,
    output_root: Optional[Path] = None,
    uploads_root: Optional[Path] = None,
) -> ConversionService:
    global _service_instance
    output_root = output_root or default_storage_root()
    service = ConversionService(
        output_root=output_root,
        uploads_root=uploads_root,
        runner=runner,
    )
    _service_instance = service
    return service
