"""CLI definitions using Typer."""

from __future__ import annotations

import asyncio
import sys
from collections import Counter
from pathlib import Path
from typing import Annotated, Optional

import typer
from rich.console import Console
from rich.table import Table

from xc_dl import __version__
from xc_dl.config import Config, load_config, save_query_preset
from xc_dl.log_setup import RingBufferHandler, setup_logging

app = typer.Typer(
    name="xc-dl",
    help="Xeno-Canto bioacoustic corpus downloader for deep learning research.",
    no_args_is_help=True,
)
console = Console()

# ── Global options stored on context ──────────────────────────────────

ConfigPath = Annotated[Optional[Path], typer.Option("--config", "-c", help="Path to config file")]
DataDir = Annotated[Optional[Path], typer.Option("--data-dir", "-d", help="Root output directory")]
ApiKey = Annotated[Optional[str], typer.Option("--api-key", help="XC API key (or XC_API_KEY env)")]
LogLevel = Annotated[str, typer.Option("--log-level", help="Logging verbosity")]
LogFile = Annotated[Optional[Path], typer.Option("--log-file", help="Log file path (JSON-lines)")]
Concurrency = Annotated[Optional[int], typer.Option("--concurrency", "-j", help="Max download concurrency")]
MetaConcurrency = Annotated[Optional[int], typer.Option("--metadata-concurrency", help="Max metadata fetch concurrency")]
RateLimit = Annotated[Optional[float], typer.Option("--rate-limit", help="Max API requests/sec ceiling")]
DryRun = Annotated[bool, typer.Option("--dry-run", help="Show plan without executing")]
Verbose = Annotated[bool, typer.Option("--verbose", "-v", help="Show log output on console (matches --log-level)")]


def _resolve_config(
    config_path: Path | None,
    data_dir: Path | None,
    api_key: str | None,
    log_level: str,
    log_file: Path | None,
    concurrency: int | None,
    metadata_concurrency: int | None,
    rate_limit: float | None,
) -> Config:
    """Load config file and overlay CLI options."""
    cfg_path = config_path or Path("./xc-dl.yaml")
    cfg = load_config(cfg_path if cfg_path.exists() else None)

    if data_dir:
        cfg.data_dir = data_dir
    if api_key:
        cfg.api_key = api_key
    if log_file:
        cfg.log_file = log_file
    if concurrency:
        cfg.concurrency = concurrency
    if metadata_concurrency:
        cfg.metadata_concurrency = metadata_concurrency
    if rate_limit:
        cfg.rate_limit = rate_limit
    cfg.log_level = log_level

    return cfg


def _require_api_key(cfg: Config) -> str:
    if not cfg.api_key:
        console.print(
            "[bold red]Error:[/] No API key provided.\n"
            "Set XC_API_KEY environment variable, pass --api-key, or add api_key to config file.\n"
            "Get your key at https://xeno-canto.org/account",
        )
        raise typer.Exit(1)
    return cfg.api_key


# ── search ────────────────────────────────────────────────────────────

@app.command()
def search(
    query: Annotated[Optional[str], typer.Argument(help="Xeno-canto query string")] = None,
    interactive: Annotated[bool, typer.Option("--interactive", "-i", help="Launch interactive search TUI")] = False,
    full: Annotated[bool, typer.Option("--full", help="Fetch all pages for exact stats")] = False,
    format: Annotated[str, typer.Option("--format", help="Output format: table, json, csv")] = "table",
    save_query: Annotated[Optional[str], typer.Option("--save-query", help="Save query as named preset")] = None,
    config: ConfigPath = None,
    data_dir: DataDir = None,
    api_key: ApiKey = None,
    log_level: LogLevel = "INFO",
    log_file: LogFile = None,
    concurrency: Concurrency = None,
    metadata_concurrency: MetaConcurrency = None,
    rate_limit: RateLimit = None,
    dry_run: DryRun = False,
    verbose: Verbose = False,
) -> None:
    """Preview a query without downloading. Shows summary statistics."""
    if not interactive and not query:
        console.print("[bold red]Error:[/] Provide a query argument or use --interactive / -i.")
        raise typer.Exit(1)

    cfg = _resolve_config(config, data_dir, api_key, log_level, log_file, concurrency, metadata_concurrency, rate_limit)
    setup_logging(cfg.log_level, cfg.log_file, console_level=cfg.log_level if verbose else "ERROR")
    key = _require_api_key(cfg)

    if interactive:
        from xc_dl.tui.app import InteractiveSearchApp

        tui = InteractiveSearchApp(api_key=key, cfg=cfg, initial_query=query or "")
        result = asyncio.run(tui.run())

        if result is None:
            return
        if result.action == "download":
            console.print(f"[bold]Query for download:[/] {result.query}")
            if result.selected_ids:
                console.print(f"  Selected {len(result.selected_ids)} recordings")
        elif result.action == "save":
            cfg_path = config or Path("./xc-dl.yaml")
            save_query_preset(cfg_path, result.preset_name, result.query, result.preset_description)
            console.print(f"Saved query preset [bold]{result.preset_name}[/]: {result.query}")
        return

    if save_query:
        cfg_path = config or Path("./xc-dl.yaml")
        save_query_preset(cfg_path, save_query, query)
        console.print(f"Saved query preset [bold]{save_query}[/]")

    asyncio.run(_search_async(query, key, cfg, full, format))


async def _search_async(query: str, api_key: str, cfg: Config, full: bool, fmt: str) -> None:
    import aiohttp
    from xc_dl.api.client import XenoCantoClient
    from xc_dl.api.models import Recording

    async with aiohttp.ClientSession() as session:
        client = XenoCantoClient(
            api_key=api_key,
            session=session,
            metadata_concurrency=cfg.metadata_concurrency,
            rate_limit=cfg.rate_limit,
        )

        first_page = await client.fetch_page(query, page=1, per_page=500)

        if full and first_page.numPages > 1:
            from rich.console import Group
            from rich.live import Live
            from rich.progress import (
                BarColumn,
                MofNCompleteColumn,
                Progress,
                SpinnerColumn,
                TextColumn,
                TimeRemainingColumn,
            )

            _overall_cols = (
                SpinnerColumn(),
                TextColumn("{task.description}"),
                BarColumn(),
                MofNCompleteColumn(),
                TimeRemainingColumn(),
            )
            meta_overall_progress = Progress(*_overall_cols)
            meta_task_progress = Progress(
                SpinnerColumn("dots"),
                TextColumn("  {task.description}"),
            )
            _stages = [(meta_overall_progress, meta_task_progress)]

            class _StageGroup:
                def __rich_console__(self, rconsole, options):
                    renderables = []
                    for overall, tasks in _stages:
                        if overall.tasks:
                            renderables.append(overall)
                            if tasks.tasks:
                                renderables.append(tasks)
                    yield from Group(*renderables).__rich_console__(rconsole, options)

            total_pages = first_page.numPages
            all_recordings: list[Recording] = []

            # Per-task spinner tracking (same pattern as download command)
            _fetch_tasks: dict[int, int] = {}  # page_num -> rich task_id
            completed = 0

            with Live(_StageGroup(), console=console, refresh_per_second=12):
                meta_overall = meta_overall_progress.add_task(
                    "Fetching metadata",
                    total=total_pages,
                )

                async def _on_page_start(page_num: int) -> None:
                    task_id = meta_task_progress.add_task(f"Page {page_num}")
                    _fetch_tasks[page_num] = task_id

                async def _on_page_done(page_num: int, response) -> None:
                    nonlocal completed
                    task_id = _fetch_tasks.pop(page_num, None)
                    if task_id is not None:
                        meta_task_progress.remove_task(task_id)
                    completed += 1
                    meta_overall_progress.update(meta_overall, completed=completed)

                async for rec in client.fetch_all_pages(
                    query,
                    per_page=500,
                    on_page_start=_on_page_start,
                    on_page=_on_page_done,
                ):
                    all_recordings.append(rec)
        else:
            all_recordings = list(first_page.recordings)

        _display_search_results(query, first_page, all_recordings, full, fmt)


def _display_search_results(query: str, first_page, recordings, full: bool, fmt: str) -> None:
    from xc_dl.utils import parse_duration

    total_recs = first_page.total_recordings
    total_species = first_page.total_species
    num_pages = first_page.numPages

    quality_counts: Counter[str] = Counter()
    type_counts: Counter[str] = Counter()
    species_counts: Counter[str] = Counter()
    bg_count = 0
    total_duration = 0.0

    for rec in recordings:
        if rec.q:
            quality_counts[rec.q] += 1
        for t in rec.type_list:
            type_counts[t] += 1
        species_counts[rec.en or rec.species_name] += 1
        if rec.also:
            bg_count += 1
        total_duration += parse_duration(rec.length)

    if fmt == "json":
        import json
        result = {
            "query": query,
            "numRecordings": total_recs,
            "numSpecies": total_species,
            "numPages": num_pages,
            "quality": dict(quality_counts.most_common()),
            "types": dict(type_counts.most_common(10)),
            "top_species": dict(species_counts.most_common(10)),
            "bg_species_count": bg_count,
            "est_duration_hours": round(total_duration / 3600, 1),
            "exact_stats": full,
        }
        console.print(json.dumps(result, indent=2))
        return

    # Table format
    console.print()
    console.print(f"[bold]Query:[/] {query}")
    console.print("─" * 50)
    console.print(f"  Recordings:  [bold]{total_recs:,}[/]")
    console.print(f"  Species:     [bold]{total_species:,}[/]")
    console.print(f"  Pages:       {num_pages}  (500 per page)")
    console.print("─" * 50)

    if quality_counts:
        q_str = "  ".join(f"{k}: {v}" for k, v in sorted(quality_counts.items()))
        console.print(f"  Quality:     {q_str}")

    if type_counts:
        t_str = ", ".join(f"{k} ({v})" for k, v in type_counts.most_common(5))
        console.print(f"  Types:       {t_str}")

    if species_counts:
        top = ", ".join(f"{k} ({v})" for k, v in species_counts.most_common(10))
        console.print(f"  Top species: {top}")

    if bg_count:
        console.print(f"  Background:  {bg_count} recordings with ≥1 bg species")

    hours = total_duration / 3600
    prefix = "~" if not full else ""
    console.print(f"  Duration:    {prefix}{hours:.1f} hours")
    est_gb = total_recs * 5 / 1024 if not full else len(recordings) * 5 / 1024
    console.print(f"  Est. disk:   ~{est_gb:.1f} GB (assuming ~5MB avg)")

    if not full and num_pages > 1:
        console.print(f"\n  [dim]Stats from page 1 only. Use --full for exact statistics.[/]")
    console.print()


# ── download ──────────────────────────────────────────────────────────

@app.command()
def download(
    query: Annotated[Optional[str], typer.Argument(help="Xeno-canto query string")] = None,
    metadata_only: Annotated[bool, typer.Option("--metadata-only", help="Fetch metadata only")] = False,
    per_page: Annotated[int, typer.Option("--per-page", help="Records per API page")] = 500,
    convert: Annotated[Optional[str], typer.Option("--convert", help="Convert to format: wav, flac, ogg")] = None,
    convert_sample_rate: Annotated[int, typer.Option("--convert-sample-rate")] = 16000,
    convert_channels: Annotated[int, typer.Option("--convert-channels")] = 1,
    convert_bit_depth: Annotated[int, typer.Option("--convert-bit-depth")] = 16,
    skip_existing: Annotated[bool, typer.Option("--skip-existing/--no-skip-existing")] = True,
    retry_failed: Annotated[bool, typer.Option("--retry-failed/--no-retry-failed")] = True,
    max_retries: Annotated[int, typer.Option("--max-retries")] = 3,
    from_file_list: Annotated[Optional[Path], typer.Option("--from-file-list")] = None,
    from_config: Annotated[Optional[str], typer.Option("--from-config", help="Named query preset")] = None,
    generate_file_lists: Annotated[bool, typer.Option("--generate-file-lists")] = False,
    num_nodes: Annotated[int, typer.Option("--num-nodes")] = 1,
    include_sonograms: Annotated[bool, typer.Option("--include-sonograms")] = False,
    sonogram_size: Annotated[str, typer.Option("--sonogram-size")] = "med",
    portion: Annotated[Optional[float], typer.Option("--portion", help="Download a random portion (0-100%%) of results")] = None,
    max_storage: Annotated[Optional[str], typer.Option("--max-storage", help="Stop after downloading this much data (e.g. 500GB, 1TiB)")] = None,
    min_free_space: Annotated[Optional[str], typer.Option("--min-free-space", help="Stop when disk free space drops below threshold (e.g. 10GB, 1TiB)")] = None,
    selector_name: Annotated[Optional[str], typer.Option("--selector-name", help="Override auto-derived selector name")] = None,
    no_selector: Annotated[bool, typer.Option("--no-selector", help="Skip selector file generation")] = False,
    config: ConfigPath = None,
    data_dir: DataDir = None,
    api_key: ApiKey = None,
    log_level: LogLevel = "INFO",
    log_file: LogFile = None,
    concurrency: Concurrency = None,
    metadata_concurrency: MetaConcurrency = None,
    rate_limit: RateLimit = None,
    dry_run: DryRun = False,
    verbose: Verbose = False,
) -> None:
    """Download recordings: fetch metadata, download audio, verify."""
    cfg = _resolve_config(config, data_dir, api_key, log_level, log_file, concurrency, metadata_concurrency, rate_limit)
    if not cfg.log_file:
        cfg.log_file = cfg.data_dir / "download.log"
    log_panel_handler = RingBufferHandler(capacity=12)
    setup_logging(cfg.log_level, cfg.log_file, console_level=cfg.log_level if verbose else "ERROR", live_handler=log_panel_handler)

    # Resolve query
    actual_query = query
    if from_config:
        preset = cfg.queries.get(from_config)
        if not preset:
            console.print(f"[bold red]Error:[/] Query preset '{from_config}' not found in config.")
            raise typer.Exit(1)
        actual_query = preset.query
        console.print(f"Using preset [bold]{from_config}[/]: {actual_query}")

    # Generate file lists mode
    if generate_file_lists:
        from xc_dl.hpc.file_list import generate_file_lists as gen_lists
        paths = gen_lists(cfg.data_dir, num_nodes)
        console.print(f"Generated {len(paths)} file lists in {cfg.data_dir / 'file-lists'}")
        return

    # From file list mode (no query needed)
    if from_file_list:
        if not from_file_list.exists():
            console.print(f"[bold red]Error:[/] File list not found: {from_file_list}")
            raise typer.Exit(1)
        asyncio.run(_download_from_file_list(
            cfg, from_file_list, skip_existing, retry_failed, max_retries,
            convert, convert_sample_rate, convert_channels, convert_bit_depth,
        ))
        return

    if not actual_query:
        console.print("[bold red]Error:[/] Provide a query argument or --from-config preset.")
        raise typer.Exit(1)

    key = _require_api_key(cfg)

    if dry_run:
        console.print(f"[bold]Dry run[/] — would download query: {actual_query}")
        console.print(f"  Data dir: {cfg.data_dir}")
        console.print(f"  Concurrency: {cfg.concurrency}")
        return

    # Parse max_storage if provided
    max_storage_bytes: int | None = None
    if max_storage:
        from xc_dl.utils import parse_size
        max_storage_bytes = parse_size(max_storage)

    # Parse min_free_space (CLI overrides config)
    min_free_space_bytes = cfg.download.min_free_space_bytes
    if min_free_space:
        from xc_dl.utils import parse_size
        min_free_space_bytes = parse_size(min_free_space)

    # Resolve selector name
    resolved_selector_name = selector_name or (from_config if from_config else None)

    try:
        asyncio.run(_download_async(
            actual_query, key, cfg, metadata_only, per_page,
            skip_existing, retry_failed, max_retries,
            convert, convert_sample_rate, convert_channels, convert_bit_depth,
            portion=portion, max_storage_bytes=max_storage_bytes,
            min_free_space_bytes=min_free_space_bytes,
            log_panel_handler=log_panel_handler,
            selector_name=resolved_selector_name,
            no_selector=no_selector,
        ))
    except KeyboardInterrupt:
        console.print("\n[yellow]Interrupted. Progress saved — re-run to resume.[/]")


async def _download_async(
    query: str,
    api_key: str,
    cfg: Config,
    metadata_only: bool,
    per_page: int,
    skip_existing: bool,
    retry_failed: bool,
    max_retries: int,
    convert: str | None,
    convert_sample_rate: int,
    convert_channels: int,
    convert_bit_depth: int,
    portion: float | None = None,
    max_storage_bytes: int | None = None,
    min_free_space_bytes: int = 1_073_741_824,
    log_panel_handler: RingBufferHandler | None = None,
    selector_name: str | None = None,
    no_selector: bool = False,
) -> None:
    import logging
    import signal
    from collections import deque

    import aiohttp
    from rich.live import Live
    from rich.progress import (
        BarColumn, DownloadColumn, MofNCompleteColumn, Progress,
        SpinnerColumn, TextColumn,
        TimeRemainingColumn, TransferSpeedColumn,
    )

    from xc_dl.api.client import XenoCantoClient
    from xc_dl.config import save_dataset_config
    from xc_dl.core.converter import check_ffmpeg
    from xc_dl.core.metadata import fetch_and_store_metadata
    from xc_dl.core.pipeline import DownloadPipeline
    from xc_dl.core.selector import derive_selector_name, write_selector
    from xc_dl.core.taxonomy import init_taxonomy

    cfg.data_dir.mkdir(parents=True, exist_ok=True)
    init_taxonomy(cfg.data_dir)

    # Graceful shutdown: first Ctrl+C sets event, second forces exit
    cancel_event = asyncio.Event()
    _sigint_count = 0

    def _on_sigint() -> None:
        nonlocal _sigint_count
        _sigint_count += 1
        if _sigint_count == 1:
            cancel_event.set()
            console.print(
                "\n[yellow]Interrupting… finishing current tasks. "
                "Press Ctrl+C again to force quit.[/]"
            )
        else:
            # Restore default handler so next Ctrl+C kills immediately
            signal.signal(signal.SIGINT, signal.SIG_DFL)

    loop = asyncio.get_running_loop()
    try:
        loop.add_signal_handler(signal.SIGINT, _on_sigint)
    except NotImplementedError:
        pass  # Windows

    async with aiohttp.ClientSession() as session:
        client = XenoCantoClient(
            api_key=api_key,
            session=session,
            metadata_concurrency=cfg.metadata_concurrency,
            rate_limit=cfg.rate_limit,
            max_retries=max_retries,
        )

        # Phase 1: Fetch metadata
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            BarColumn(),
            MofNCompleteColumn(),
            TimeRemainingColumn(),
            console=console,
        ) as progress:
            meta_task = progress.add_task("Fetching metadata", total=None)

            async def meta_progress(fetched: int, total: int) -> None:
                progress.update(meta_task, total=total, completed=fetched)

            entries = await fetch_and_store_metadata(
                client, query, cfg.data_dir, per_page=per_page,
                progress_callback=meta_progress,
            )

        console.print(f"  Metadata: [bold]{len(entries):,}[/] recordings")

        if metadata_only:
            if not no_selector and entries:
                sel_name = selector_name or derive_selector_name(query, selector_name)
                sel_path = write_selector(cfg.data_dir, sel_name, query, entries)
                if sel_path:
                    console.print(f"  Selector: [bold]{sel_path.name}[/]")
            console.print("[green]Metadata-only mode — done.[/]")
            return

        if not entries:
            console.print("[yellow]No recordings found.[/]")
            return

        # Apply --portion sampling
        if portion is not None:
            import hashlib
            import random as random_mod
            seed = int(hashlib.sha256(query.encode()).hexdigest()[:8], 16)
            rng = random_mod.Random(seed)
            k = max(1, int(len(entries) * portion / 100))
            entries = rng.sample(entries, min(k, len(entries)))
            console.print(f"  Portion: sampling [bold]{len(entries):,}[/] recordings ({portion}%)")

        # Check ffmpeg availability before pipeline if conversion requested
        if convert and not check_ffmpeg():
            console.print("[bold red]Error:[/] ffmpeg not found. Install ffmpeg for audio conversion.")
            return

        # Save dataset config (never includes API key)
        save_dataset_config(
            cfg.data_dir, query,
            per_page=per_page,
            convert=convert,
            convert_sample_rate=convert_sample_rate,
            convert_channels=convert_channels,
            convert_bit_depth=convert_bit_depth,
            concurrency=cfg.concurrency,
            metadata_concurrency=cfg.metadata_concurrency,
            portion=portion,
            max_storage=max_storage_bytes,
            min_free_space=min_free_space_bytes,
        )

        # ── Multi-section progress display ──
        # Each stage gets its own overall Progress + per-file Progress,
        # rendered as paired sections via a custom renderable.

        # Overall bar columns
        _overall_cols = (
            SpinnerColumn(), TextColumn("{task.description}"),
            BarColumn(), MofNCompleteColumn(), TimeRemainingColumn(),
        )

        # Stage: Download
        dl_overall_progress = Progress(*_overall_cols)
        dl_file_progress = Progress(
            TextColumn("  {task.description}"),
            BarColumn(), DownloadColumn(), TransferSpeedColumn(),
        )
        # Stage: Verify
        verify_overall_progress = Progress(*_overall_cols)
        verify_file_progress = Progress(
            SpinnerColumn("dots"), TextColumn("  {task.description}"),
        )
        # Stage: Convert (lazy — only shown when --convert)
        convert_overall_progress = Progress(*_overall_cols)
        convert_file_progress = Progress(
            SpinnerColumn("dots"), TextColumn("  {task.description}"),
        )
        # Stage: Conv-Verify (lazy)
        cv_overall_progress = Progress(*_overall_cols)
        cv_file_progress = Progress(
            SpinnerColumn("dots"), TextColumn("  {task.description}"),
        )

        # Pairs: (overall_progress, file_progress) — only rendered when overall has tasks
        _stages: list[tuple[Progress, Progress]] = [
            (dl_overall_progress, dl_file_progress),
            (verify_overall_progress, verify_file_progress),
            (convert_overall_progress, convert_file_progress),
            (cv_overall_progress, cv_file_progress),
        ]

        class _StageGroup:
            """Renderable that interleaves each stage's overall bar with its file tasks."""
            def __rich_console__(self, console, options):
                from rich.console import Group
                from rich.panel import Panel
                from rich.text import Text

                renderables = []
                for overall, files in _stages:
                    if overall.tasks:
                        renderables.append(overall)
                        if files.tasks:
                            renderables.append(files)

                if log_panel_handler and log_panel_handler.records:
                    log_text = Text("\n".join(log_panel_handler.records))
                    renderables.append(Panel(
                        log_text, title="Logs", style="dim",
                        border_style="dim",
                        height=min(len(log_panel_handler.records) + 2, 14),
                    ))

                yield from Group(*renderables).__rich_console__(console, options)

        with Live(_StageGroup(), console=console, refresh_per_second=12):
            # Overall bars (download + verify always present)
            dl_overall = dl_overall_progress.add_task("Downloading", total=len(entries))
            dl_completed = 0
            verify_overall = verify_overall_progress.add_task("Verifying", total=len(entries))
            verify_completed = 0
            conv_overall: int | None = None
            conv_completed = 0
            cv_overall: int | None = None
            cv_completed = 0

            # -- Download per-file callbacks --
            _dl_tasks: dict[int, int] = {}
            _dl_counter = 0

            async def on_download_start(xc_id: str, filename: str, total_bytes: int | None) -> int:
                nonlocal _dl_counter
                _dl_counter += 1
                short = filename[:35] if len(filename) > 35 else filename
                task_id = dl_file_progress.add_task(
                    short,
                    total=total_bytes if total_bytes else None,
                )
                _dl_tasks[_dl_counter] = task_id
                return _dl_counter

            async def on_download_progress(counter: int, received: int) -> None:
                task_id = _dl_tasks.get(counter)
                if task_id is not None:
                    dl_file_progress.update(task_id, completed=received)

            async def on_download_done(counter: int) -> None:
                task_id = _dl_tasks.pop(counter, None)
                if task_id is not None:
                    dl_file_progress.remove_task(task_id)

            async def on_overall_download() -> None:
                nonlocal dl_completed
                dl_completed += 1
                dl_overall_progress.update(dl_overall, completed=dl_completed)

            # -- Verify per-file callbacks --
            _verify_tasks: dict[int, int] = {}
            _verify_counter = 0

            async def on_verify_start(xc_id: str, filename: str) -> int:
                nonlocal _verify_counter
                _verify_counter += 1
                short = filename[:35] if len(filename) > 35 else filename
                task_id = verify_file_progress.add_task(f"SHA256 {short}")
                _verify_tasks[_verify_counter] = task_id
                return _verify_counter

            async def on_verify_done(counter: int) -> None:
                task_id = _verify_tasks.pop(counter, None)
                if task_id is not None:
                    verify_file_progress.remove_task(task_id)

            async def on_overall_verify() -> None:
                nonlocal verify_completed
                verify_completed += 1
                verify_overall_progress.update(verify_overall, completed=verify_completed)

            # -- Convert per-file callbacks --
            _conv_tasks: dict[int, int] = {}
            _conv_counter = 0

            async def on_convert_start(xc_id: str, filename: str) -> int:
                nonlocal _conv_counter, conv_overall
                if conv_overall is None:
                    conv_overall = convert_overall_progress.add_task("Converting", total=len(entries))
                _conv_counter += 1
                short = filename[:35] if len(filename) > 35 else filename
                task_id = convert_file_progress.add_task(f"ffmpeg {short}")
                _conv_tasks[_conv_counter] = task_id
                return _conv_counter

            async def on_convert_done(counter: int) -> None:
                task_id = _conv_tasks.pop(counter, None)
                if task_id is not None:
                    convert_file_progress.remove_task(task_id)

            async def on_overall_convert() -> None:
                nonlocal conv_overall, conv_completed
                if conv_overall is None:
                    conv_overall = convert_overall_progress.add_task("Converting", total=len(entries))
                conv_completed += 1
                convert_overall_progress.update(conv_overall, completed=conv_completed)

            # -- Convert-verify per-file callbacks --
            _cv_tasks: dict[int, int] = {}
            _cv_counter = 0

            async def on_convert_verify_start(xc_id: str, filename: str) -> int:
                nonlocal _cv_counter, cv_overall
                if cv_overall is None:
                    cv_overall = cv_overall_progress.add_task("Conv-Verify", total=len(entries))
                _cv_counter += 1
                short = filename[:35] if len(filename) > 35 else filename
                task_id = cv_file_progress.add_task(f"SHA256 {short}")
                _cv_tasks[_cv_counter] = task_id
                return _cv_counter

            async def on_convert_verify_done(counter: int) -> None:
                task_id = _cv_tasks.pop(counter, None)
                if task_id is not None:
                    cv_file_progress.remove_task(task_id)

            async def on_overall_convert_verify() -> None:
                nonlocal cv_overall, cv_completed
                if cv_overall is None:
                    cv_overall = cv_overall_progress.add_task("Conv-Verify", total=len(entries))
                cv_completed += 1
                cv_overall_progress.update(cv_overall, completed=cv_completed)

            pipeline = DownloadPipeline(
                session=session,
                data_dir=cfg.data_dir,
                cancel_event=cancel_event,
                concurrency=cfg.concurrency,
                max_retries=max_retries,
                skip_existing=skip_existing,
                retry_failed=retry_failed,
                convert_format=convert,
                convert_sample_rate=convert_sample_rate,
                convert_channels=convert_channels,
                convert_bit_depth=convert_bit_depth,
                max_storage_bytes=max_storage_bytes,
                min_free_space_bytes=min_free_space_bytes,
                on_download_start=on_download_start,
                on_download_progress=on_download_progress,
                on_download_done=on_download_done,
                on_verify_start=on_verify_start,
                on_verify_done=on_verify_done,
                on_convert_start=on_convert_start,
                on_convert_done=on_convert_done,
                on_convert_verify_start=on_convert_verify_start,
                on_convert_verify_done=on_convert_verify_done,
                on_overall_download=on_overall_download,
                on_overall_verify=on_overall_verify,
                on_overall_convert=on_overall_convert,
                on_overall_convert_verify=on_overall_convert_verify,
            )

            stats = await pipeline.run(entries)

        # Write selector file
        if not no_selector and entries:
            sel_name = selector_name or derive_selector_name(query, selector_name)
            sel_path = write_selector(cfg.data_dir, sel_name, query, entries)
            if sel_path:
                console.print(f"  Selector: [bold]{sel_path.name}[/]")

        # Print summary
        console.print(
            f"  Downloads: [green]{stats.download_ok} ok[/], "
            f"[red]{stats.download_failed} failed[/], "
            f"[dim]{stats.download_skipped} skipped[/]"
        )
        if convert:
            console.print(
                f"  Conversion: [green]{stats.convert_ok} ok[/], "
                f"[red]{stats.convert_failed} failed[/]"
            )

        if cancel_event.is_set():
            console.print("[yellow]Interrupted. Progress saved — re-run to resume.[/]")
        else:
            console.print("[green]Done.[/]")


async def _download_from_file_list(
    cfg: Config,
    file_list_path: Path,
    skip_existing: bool,
    retry_failed: bool,
    max_retries: int,
    convert: str | None,
    convert_sample_rate: int,
    convert_channels: int,
    convert_bit_depth: int,
) -> None:
    """Download from a pre-generated file list (HPC mode)."""
    import aiohttp
    from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn

    from xc_dl.core.downloader import download_recordings
    from xc_dl.core.manifest import Manifest
    from xc_dl.core.taxonomy import init_taxonomy
    from xc_dl.hpc.file_list import load_file_list

    init_taxonomy(cfg.data_dir)

    file_ids = load_file_list(file_list_path)
    manifest = Manifest(cfg.data_dir)
    all_entries = manifest.read_all()
    entries = [e for xc_id, e in all_entries.items() if xc_id in file_ids]

    if not entries:
        console.print("[yellow]No matching entries in manifest.[/]")
        return

    # Derive node suffix from filename
    node_suffix = ""
    stem = file_list_path.stem
    if stem.startswith("node-"):
        node_suffix = f"-{stem}"

    key = _require_api_key(cfg)

    async with aiohttp.ClientSession() as session:
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            BarColumn(),
            MofNCompleteColumn(),
            TimeRemainingColumn(),
            console=console,
        ) as progress:
            dl_task = progress.add_task("Downloading", total=len(entries))
            count = 0

            async def cb() -> None:
                nonlocal count
                count += 1
                progress.update(dl_task, completed=count)

            succeeded, failed, skipped = await download_recordings(
                session,
                entries,
                cfg.data_dir,
                concurrency=cfg.concurrency,
                max_retries=max_retries,
                retry_failed=retry_failed,
                skip_existing=skip_existing,
                file_list_ids=file_ids,
                node_suffix=node_suffix,
                overall_callback=cb,
            )

        console.print(f"  [green]{succeeded} ok[/], [red]{failed} failed[/], [dim]{skipped} skipped[/]")


# ── check ─────────────────────────────────────────────────────────────

@app.command()
def check(
    query: Annotated[Optional[str], typer.Option("--query", help="Check only recordings matching query")] = None,
    all: Annotated[bool, typer.Option("--all", help="Check all recordings")] = False,
    deep: Annotated[bool, typer.Option("--deep", help="Fully decode audio to detect corruption")] = False,
    fix: Annotated[bool, typer.Option("--fix", help="Re-download files that fail verification")] = False,
    report: Annotated[Optional[Path], typer.Option("--report", help="Write report to file")] = None,
    config: ConfigPath = None,
    data_dir: DataDir = None,
    api_key: ApiKey = None,
    log_level: LogLevel = "INFO",
    log_file: LogFile = None,
    concurrency: Concurrency = None,
    metadata_concurrency: MetaConcurrency = None,
    rate_limit: RateLimit = None,
    dry_run: DryRun = False,
    verbose: Verbose = False,
) -> None:
    """Verify integrity of an existing dataset."""
    cfg = _resolve_config(config, data_dir, api_key, log_level, log_file, concurrency, metadata_concurrency, rate_limit)
    setup_logging(cfg.log_level, cfg.log_file, console_level=cfg.log_level if verbose else "ERROR")

    if not all and not query:
        # Auto-detect: check if catalog exists in data_dir
        catalog_path = cfg.data_dir / "catalog.jsonl"
        if catalog_path.exists():
            all = True
        else:
            console.print(
                "[bold red]Error:[/] No catalog found. "
                "Specify --all or --query, or run from a dataset directory."
            )
            raise typer.Exit(1)

    asyncio.run(_check_async(cfg, deep, fix, report))


async def _check_async(cfg: Config, deep: bool, fix: bool, report_path: Path | None) -> None:
    import json

    from rich.console import Group
    from rich.live import Live
    from rich.progress import (
        BarColumn, DownloadColumn, MofNCompleteColumn, Progress,
        SpinnerColumn, TextColumn, TimeRemainingColumn, TransferSpeedColumn,
    )

    from xc_dl.config import load_dataset_config
    from xc_dl.core.manifest import Manifest
    from xc_dl.core.verifier import compute_sha256, deep_verify, probe_audio_async

    manifest = Manifest(cfg.data_dir)
    entries = manifest.read_all()

    if not entries:
        console.print("[yellow]No entries in manifest.[/]")
        return

    # Auto-detect whether conversion was enabled
    ds_config = load_dataset_config(cfg.data_dir)
    convert_enabled = bool(
        ds_config
        and ds_config.get("parameters", {}).get("convert")
    )

    results: list[dict] = []
    missing = 0
    corrupted = 0
    ok_count = 0
    conv_missing = 0
    conv_corrupted = 0
    conv_ok = 0
    duration_mismatches = 0

    # Duration tolerance for sanity check (seconds)
    _DURATION_TOLERANCE = 1.0

    # ── Multi-stage progress display (mirrors download command) ──
    _overall_cols = (
        SpinnerColumn(), TextColumn("{task.description}"),
        BarColumn(), MofNCompleteColumn(), TimeRemainingColumn(),
    )

    # Stage 1: Hash verify originals
    hash_overall_progress = Progress(*_overall_cols)
    hash_file_progress = Progress(
        SpinnerColumn("dots"), TextColumn("  {task.description}"),
    )

    # Stage 2: Deep verify originals (only if --deep)
    deep_overall_progress = Progress(*_overall_cols)
    deep_file_progress = Progress(
        SpinnerColumn("dots"), TextColumn("  {task.description}"),
    )

    # Stage 3: Hash verify converted (only if conversion enabled)
    conv_overall_progress = Progress(*_overall_cols)
    conv_file_progress = Progress(
        SpinnerColumn("dots"), TextColumn("  {task.description}"),
    )

    # Stage 4: Deep verify converted + duration sanity (only if --deep and conversion)
    conv_deep_overall_progress = Progress(*_overall_cols)
    conv_deep_file_progress = Progress(
        SpinnerColumn("dots"), TextColumn("  {task.description}"),
    )

    # Stage 5: Fix/re-download (only if --fix and failures)
    fix_overall_progress = Progress(*_overall_cols)
    fix_file_progress = Progress(
        TextColumn("  {task.description}"),
        BarColumn(), DownloadColumn(), TransferSpeedColumn(),
    )

    _stages: list[tuple[Progress, Progress]] = [
        (hash_overall_progress, hash_file_progress),
        (deep_overall_progress, deep_file_progress),
        (conv_overall_progress, conv_file_progress),
        (conv_deep_overall_progress, conv_deep_file_progress),
        (fix_overall_progress, fix_file_progress),
    ]

    class _StageGroup:
        def __rich_console__(self, rconsole, options):
            renderables = []
            for overall, files in _stages:
                if overall.tasks:
                    renderables.append(overall)
                    if files.tasks:
                        renderables.append(files)
            yield from Group(*renderables).__rich_console__(rconsole, options)

    with Live(_StageGroup(), console=console, refresh_per_second=12):
        # -- Stage 1: Hash verification of originals (concurrent) --
        hash_overall = hash_overall_progress.add_task("Verifying originals", total=len(entries))
        hash_completed = 0
        sem = asyncio.Semaphore(cfg.concurrency)

        needs_deep: list[tuple[str, object]] = []
        lock = asyncio.Lock()

        async def _check_one(xc_id: str, entry) -> None:
            nonlocal hash_completed, missing, corrupted, ok_count
            async with sem:
                file_path = cfg.data_dir / entry.original_path
                short = Path(entry.original_path).name[:35]
                task_id = hash_file_progress.add_task(f"SHA256 {short}")

                status = "ok"
                if not file_path.exists():
                    status = "missing"
                elif entry.sha256_original:
                    sha = await compute_sha256(file_path)
                    if sha != entry.sha256_original:
                        status = "corrupted"

                hash_file_progress.remove_task(task_id)
                async with lock:
                    hash_completed += 1
                    hash_overall_progress.update(hash_overall, completed=hash_completed)
                    if status == "missing":
                        missing += 1
                    elif status == "corrupted":
                        corrupted += 1
                    else:
                        ok_count += 1
                        if deep and entry.sha256_original:
                            needs_deep.append((xc_id, entry))
                    results.append({"xc_id": xc_id, "path": entry.original_path, "status": status})

        await asyncio.gather(*[_check_one(xc_id, entry) for xc_id, entry in entries.items()])

        # -- Stage 2: Deep verify originals (if --deep, concurrent) --
        if deep and needs_deep:
            deep_overall_task = deep_overall_progress.add_task("Deep verify originals", total=len(needs_deep))
            deep_completed = 0

            async def _deep_one(xc_id: str, entry) -> None:
                nonlocal deep_completed, corrupted, ok_count
                async with sem:
                    file_path = cfg.data_dir / entry.original_path
                    short = Path(entry.original_path).name[:35]
                    task_id = deep_file_progress.add_task(f"ffmpeg {short}")
                    passed = await deep_verify(file_path)
                    deep_file_progress.remove_task(task_id)
                    async with lock:
                        deep_completed += 1
                        deep_overall_progress.update(deep_overall_task, completed=deep_completed)
                        if not passed:
                            corrupted += 1
                            ok_count -= 1
                            for r in results:
                                if r["xc_id"] == xc_id:
                                    r["status"] = "corrupted"
                                    break

            await asyncio.gather(*[_deep_one(xc_id, e) for xc_id, e in needs_deep])

        # -- Stage 3: Hash verify converted files (if conversion enabled) --
        needs_conv_deep: list[tuple[str, object]] = []
        if convert_enabled:
            conv_entries = [
                (xc_id, e) for xc_id, e in entries.items()
                if e.resampled_path and e.sha256_resampled
            ]
            if conv_entries:
                conv_overall = conv_overall_progress.add_task(
                    "Verifying converted", total=len(conv_entries),
                )
                conv_completed = 0

                async def _check_conv(xc_id: str, entry) -> None:
                    nonlocal conv_completed, conv_missing, conv_corrupted, conv_ok
                    async with sem:
                        file_path = cfg.data_dir / entry.resampled_path
                        short = Path(entry.resampled_path).name[:35]
                        task_id = conv_file_progress.add_task(f"SHA256 {short}")

                        status = "ok"
                        if not file_path.exists():
                            status = "conv_missing"
                        else:
                            sha = await compute_sha256(file_path)
                            if sha != entry.sha256_resampled:
                                status = "conv_corrupted"

                        conv_file_progress.remove_task(task_id)
                        async with lock:
                            conv_completed += 1
                            conv_overall_progress.update(conv_overall, completed=conv_completed)
                            if status == "conv_missing":
                                conv_missing += 1
                            elif status == "conv_corrupted":
                                conv_corrupted += 1
                            else:
                                conv_ok += 1
                                if deep:
                                    needs_conv_deep.append((xc_id, entry))
                            results.append({
                                "xc_id": xc_id,
                                "path": entry.resampled_path,
                                "status": status,
                            })

                await asyncio.gather(*[_check_conv(xc_id, e) for xc_id, e in conv_entries])

        # -- Stage 4: Deep verify converted + duration sanity (if --deep) --
        if deep and needs_conv_deep:
            conv_deep_overall = conv_deep_overall_progress.add_task(
                "Deep verify converted", total=len(needs_conv_deep),
            )
            conv_deep_completed = 0

            async def _deep_conv_one(xc_id: str, entry) -> None:
                nonlocal conv_deep_completed, conv_corrupted, conv_ok, duration_mismatches
                async with sem:
                    file_path = cfg.data_dir / entry.resampled_path
                    short = Path(entry.resampled_path).name[:35]
                    task_id = conv_deep_file_progress.add_task(f"ffmpeg {short}")

                    failed = False
                    # Full decode check
                    if not await deep_verify(file_path):
                        failed = True
                    # Duration sanity check against original metadata
                    elif entry.length_seconds and entry.length_seconds > 0:
                        info = await probe_audio_async(file_path)
                        if info and "format" in info:
                            conv_duration = float(info["format"].get("duration", 0))
                            if abs(conv_duration - entry.length_seconds) > _DURATION_TOLERANCE:
                                failed = True
                                async with lock:
                                    duration_mismatches += 1

                    conv_deep_file_progress.remove_task(task_id)
                    async with lock:
                        conv_deep_completed += 1
                        conv_deep_overall_progress.update(
                            conv_deep_overall, completed=conv_deep_completed,
                        )
                        if failed:
                            conv_corrupted += 1
                            conv_ok -= 1
                            for r in results:
                                if r["xc_id"] == xc_id and r["path"] == entry.resampled_path:
                                    r["status"] = "conv_corrupted"
                                    break

            await asyncio.gather(*[_deep_conv_one(xc_id, e) for xc_id, e in needs_conv_deep])

    # -- Summary --
    console.print(
        f"\n  Originals:  [green]{ok_count} ok[/], "
        f"[yellow]{missing} missing[/], [red]{corrupted} corrupted[/]"
    )
    if convert_enabled:
        conv_detail = (
            f"  Converted:  [green]{conv_ok} ok[/], "
            f"[yellow]{conv_missing} missing[/], [red]{conv_corrupted} corrupted[/]"
        )
        if duration_mismatches:
            conv_detail += f" ([red]{duration_mismatches} duration mismatch[/])"
        console.print(conv_detail)

    if report_path:
        report_path.parent.mkdir(parents=True, exist_ok=True)
        with open(report_path, "w") as f:
            json.dump(results, f, indent=2)
        console.print(f"  Report written to {report_path}")

    # -- Stage 5: Fix (re-download failures) --
    total_failures = missing + corrupted + conv_missing + conv_corrupted
    if fix and total_failures > 0:
        bad_ids = {r["xc_id"] for r in results if r["status"] != "ok"}
        to_fix = [entry for xc_id, entry in entries.items() if xc_id in bad_ids]

        import aiohttp
        from xc_dl.core.downloader import download_recordings

        key = _require_api_key(cfg)
        console.print(f"[bold]Re-downloading {len(to_fix)} failed files...[/]")
        async with aiohttp.ClientSession() as session:
            s, f, sk = await download_recordings(
                session, to_fix, cfg.data_dir,
                concurrency=cfg.concurrency,
                skip_existing=False,
                retry_failed=True,
            )
            console.print(f"  Fix: [green]{s} ok[/], [red]{f} failed[/]")


# ── version ───────────────────────────────────────────────────────────

def _version_callback(value: bool) -> None:
    if value:
        console.print(f"xc-dl {__version__}")
        raise typer.Exit()


@app.callback()
def main(
    version: Annotated[
        bool, typer.Option("--version", "-V", callback=_version_callback, is_eager=True, help="Show version")
    ] = False,
) -> None:
    """Xeno-Canto bioacoustic corpus downloader."""
