"""Command-line interface for Markitai."""

from __future__ import annotations

import asyncio
import os
import sys
from pathlib import Path

# Fix Windows console encoding for Unicode output
if sys.platform == "win32":
    # Set UTF-8 mode for Windows console
    # sys.stdout/stderr are actually io.TextIOWrapper which has reconfigure()
    # but typed as TextIO for compatibility. hasattr check ensures safety.
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[union-attr]
    if hasattr(sys.stderr, "reconfigure"):
        sys.stderr.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[union-attr]

# Suppress noisy messages before imports
# Note: Most warning filters are now centralized in logging_config.setup_logging()
os.environ.setdefault("PYMUPDF_SUGGEST_LAYOUT_ANALYZER", "0")
# Avoid LiteLLM startup network fetches and fallback warnings in normal CLI use.
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")

import click
from dotenv import load_dotenv

# Load .env: cwd first (project-level), then ~/.markitai/ (global fallback).
# override=False (default) means first-loaded values win → cwd takes priority.
load_dotenv(Path.cwd() / ".env")
load_dotenv(Path.home() / ".markitai" / ".env")

from click import Context
from loguru import logger

from markitai.cli.console import get_console, get_stderr_console
from markitai.cli.framework import MarkitaiGroup
from markitai.cli.logging_config import (
    print_version,
    setup_logging,
)
from markitai.cli.processors.validators import (
    check_vision_model_config as _check_vision_model_config,
)
from markitai.config import ConfigManager, EnvVarNotFoundError

# Import utilities from refactored modules
from markitai.utils.cli_helpers import (
    is_url,
)
from markitai.utils.executor import shutdown_converter_executor

console = get_console()
# Separate stderr console for status/progress (doesn't mix with stdout output)
stderr_console = get_stderr_console()


# =============================================================================
# Main CLI app
# =============================================================================


def run_interactive_mode(ctx: click.Context) -> None:
    """Run interactive mode and execute with gathered options."""
    from markitai.cli.interactive import run_interactive, session_to_cli_args

    try:
        session = run_interactive()

        # Ask for confirmation before executing
        import questionary

        confirm_result = questionary.confirm(
            "Execute conversion with these settings?", default=True
        ).ask()
        if confirm_result is None:
            raise KeyboardInterrupt
        if confirm_result:
            # Re-invoke the CLI with the gathered arguments
            args = session_to_cli_args(session)
            # Use sys.executable -m for reliable cross-platform invocation
            # sys.argv[0] can be a .py file on Windows, breaking re-invocation
            import subprocess

            result = subprocess.run([sys.executable, "-m", "markitai"] + args)
            ctx.exit(result.returncode)
        else:
            click.echo("Cancelled.")
            ctx.exit(0)
    except (KeyboardInterrupt, EOFError):
        click.echo("\nCancelled.")
        ctx.exit(0)


@click.group(
    cls=MarkitaiGroup,
    invoke_without_command=True,
    context_settings={"help_option_names": ["-h", "--help"]},
)
@click.option(
    "--output",
    "-o",
    type=click.Path(path_type=Path),
    default=None,
    help="Output directory. If not specified, output to stdout.",
)
@click.option(
    "--config",
    "-c",
    "config_path",
    type=click.Path(exists=True, path_type=Path),
    default=None,
    help="Path to configuration file.",
)
@click.option(
    "--preset",
    "-p",
    type=str,
    default=None,
    help="Use a preset configuration (rich/standard/minimal).",
)
@click.option(
    "--llm/--no-llm",
    default=None,
    help="Enable/disable LLM processing.",
)
@click.option(
    "--alt/--no-alt",
    default=None,
    help="Enable/disable alt text generation for images (requires --llm).",
)
@click.option(
    "--desc/--no-desc",
    default=None,
    help="Enable/disable JSON description file for images (requires --llm).",
)
@click.option(
    "--ocr/--no-ocr",
    default=None,
    help="Enable/disable OCR for scanned documents (uses RapidOCR).",
)
@click.option(
    "--screenshot/--no-screenshot",
    default=None,
    help="Enable/disable screenshots (PDF/PPTX pages; full-page for URLs).",
)
@click.option(
    "--screenshot-only",
    is_flag=True,
    help="Capture screenshots only. Without --llm: saves only screenshots. "
    "With --llm: LLM extracts content purely from screenshots.",
)
@click.option(
    "--resume",
    is_flag=True,
    help="Resume interrupted batch processing.",
)
@click.option(
    "--no-compress",
    is_flag=True,
    help="Disable image compression.",
)
@click.option(
    "--no-cache",
    is_flag=True,
    help="Disable LLM result caching (force fresh API calls).",
)
@click.option(
    "--no-cache-for",
    type=str,
    default=None,
    help="Disable cache for specific files/patterns (comma-separated, supports glob). "
    "E.g., 'file.pdf', '*.docx', '**/reports/*.pdf'.",
)
@click.option(
    "--llm-concurrency",
    type=click.IntRange(min=1),
    default=None,
    help="Number of concurrent LLM requests (default from config).",
)
@click.option(
    "--batch-concurrency",
    "-j",
    type=click.IntRange(min=1),
    default=None,
    help="Number of concurrent batch tasks (default from config).",
)
@click.option(
    "--url-concurrency",
    type=click.IntRange(min=1),
    default=None,
    help="Number of concurrent URL fetches (default from config, separate from file processing).",
)
@click.option(
    "--glob",
    "-g",
    "glob_patterns",
    multiple=True,
    help="Restrict directory batch discovery to matching relative paths. Repeatable. Prefix with ! to exclude; use single quotes in shells with history expansion. Only applies to directory input.",
)
@click.option(
    "--max-depth",
    type=click.IntRange(min=0),
    default=None,
    help="Override recursive directory scan depth for batch discovery. 0 = only the input directory.",
)
@click.option(
    "--playwright",
    "use_playwright",
    is_flag=True,
    help="Force browser rendering for URLs via Playwright.",
)
@click.option(
    "--defuddle",
    "use_defuddle",
    is_flag=True,
    help="Force Defuddle API for URL fetching (free, no auth, best content cleaning).",
)
@click.option(
    "--static",
    "use_static",
    is_flag=True,
    help="Force static HTTP fetch with native webextract (no external API).",
)
@click.option(
    "--jina",
    "use_jina",
    is_flag=True,
    help="Force Jina Reader API for URL fetching.",
)
@click.option(
    "--cloudflare",
    "use_cloudflare",
    is_flag=True,
    help="Use Cloudflare cloud backend (BR for URLs, toMarkdown for files). "
    "Requires CF API token and account ID in config. Available on CF Free plan.",
)
@click.option(
    "--kreuzberg",
    "use_kreuzberg",
    is_flag=True,
    help="Force kreuzberg converter for all file formats (overrides native converters). "
    "Requires kreuzberg installed: uv pip install markitai[kreuzberg].",
)
@click.option(
    "-v",
    "--verbose",
    is_flag=True,
    help="Enable verbose output.",
)
@click.option(
    "--quiet",
    "-q",
    is_flag=True,
    help="Suppress progress and info messages, only show errors.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview conversion without writing files.",
)
@click.option(
    "--pure",
    is_flag=True,
    help="Pure mode: skip frontmatter and post-processing. With --llm: raw MD → LLM → output.",
)
@click.option(
    "--keep-base",
    is_flag=True,
    help="Keep base .md file alongside .llm.md in LLM mode.",
)
@click.option(
    "--interactive",
    "-I",
    is_flag=True,
    is_eager=True,
    expose_value=False,
    callback=lambda ctx, _param, value: run_interactive_mode(ctx) if value else None,
    help="Enter interactive mode for guided setup.",
)
@click.option(
    "--version",
    "-V",
    is_flag=True,
    callback=print_version,
    expose_value=False,
    is_eager=True,
    help="Show version and exit.",
)
@click.pass_context
def app(
    ctx: Context,
    output: Path | None,
    config_path: Path | None,
    preset: str | None,
    llm: bool | None,
    alt: bool | None,
    desc: bool | None,
    ocr: bool | None,
    screenshot: bool | None,
    screenshot_only: bool,
    resume: bool,
    no_compress: bool,
    no_cache: bool,
    no_cache_for: str | None,
    batch_concurrency: int | None,
    url_concurrency: int | None,
    llm_concurrency: int | None,
    glob_patterns: tuple[str, ...],
    max_depth: int | None,
    use_defuddle: bool,
    use_playwright: bool,
    use_static: bool,
    use_jina: bool,
    use_cloudflare: bool,
    use_kreuzberg: bool,
    verbose: bool,
    quiet: bool,
    dry_run: bool,
    pure: bool,
    keep_base: bool,
) -> None:
    """Markitai - Opinionated Markdown converter with native LLM enhancement support.

    Convert various document formats and URLs to Markdown with optional
    LLM-powered enhancement for format optimization and image analysis.

    \b
    Presets:
        rich     - LLM + alt + desc + screenshot (complex documents)
        standard - LLM + alt + desc (normal documents)
        minimal  - No enhancement (just convert)

    \b
    Examples:
        markitai document.docx                      # Convert single file
        markitai https://example.com/page           # Convert web page
        markitai urls.urls -o ./output/             # Batch URL processing
        markitai https://youtube.com/watch?v=abc    # Convert YouTube video
        markitai document.pdf --preset rich         # Use rich preset
        markitai document.pdf --preset rich --ocr   # Rich + OCR for scans
        markitai document.pdf --preset rich --no-desc  # Rich without desc
        markitai ./docs/ -o ./output/ --resume      # Batch conversion
        markitai config list                        # Show configuration
    """
    # If subcommand is invoked, setup logging (quiet console) and let it handle
    if ctx.invoked_subcommand is not None:
        setup_logging(verbose=False, quiet=True)
        return

    # Get input path from context (set by MarkitaiGroup.parse_args)
    ctx.ensure_object(dict)
    input_path_str = ctx.obj.get("_input_path")

    if not input_path_str:
        click.echo(ctx.get_help())
        ctx.exit(0)

    # Check if input is a URL
    is_url_input = is_url(input_path_str)

    # Initialize URL list mode variables
    url_entries: list = []
    is_url_list_mode = False
    input_path: Path | None = None

    # For file/directory inputs, validate existence and check for .urls file
    if not is_url_input:
        input_path = Path(input_path_str)
        if not input_path.exists():
            console.print(f"[red]Error: Path '{input_path}' does not exist.[/red]")
            ctx.exit(1)

        # Auto-detect .urls file
        if input_path.is_file() and input_path.suffix == ".urls":
            from markitai.urls import UrlListParseError, parse_url_list

            try:
                url_entries = parse_url_list(input_path)
            except UrlListParseError as e:
                console.print(f"[red]Error parsing URL list: {e}[/red]")
                ctx.exit(1)

            if not url_entries:
                console.print(f"[yellow]No valid URLs found in {input_path}[/yellow]")
                ctx.exit(0)

            is_url_list_mode = True
            input_path = None  # Clear input_path for URL list mode

    # Load configuration first
    config_manager = ConfigManager()
    cfg = config_manager.load(config_path=config_path)

    # Determine if we're in single file/URL mode (not batch)
    # Single file/URL mode: quiet console unless --verbose is specified
    # URL list mode is batch mode
    is_single_mode = (
        is_url_input or (input_path is not None and input_path.is_file())
    ) and not is_url_list_mode
    # Determine if we're in stdout mode (single file/URL without -o)
    # In stdout mode, output IS the content, so suppress ALL console logs
    is_stdout_mode = is_single_mode and output is None
    # Enable quiet mode if: explicitly requested via --quiet,
    # in stdout mode (regardless of --verbose), or in single mode without --verbose
    quiet_console = quiet or is_stdout_mode or (is_single_mode and not verbose)

    # Setup logging with configuration
    console_handler_id, log_file_path = setup_logging(
        verbose=verbose,
        log_dir=cfg.log.dir,
        log_level=cfg.log.level,
        log_format=cfg.log.format,
        rotation=cfg.log.rotation,
        retention=cfg.log.retention,
        quiet=quiet_console,
    )

    # Log configuration status after logging is set up
    if config_manager.config_path:
        logger.debug(f"[Config] Loaded from: {config_manager.config_path}")
    else:
        logger.warning("[Config] No config file found, using defaults")

    # Auto-populate model_list when LLM enabled but no models configured
    if cfg.llm.enabled and not cfg.llm.model_list:
        # Priority 1: MODEL env var (explicit single-model override)
        model_env = os.environ.get("MODEL")
        if model_env:
            from markitai.config import LiteLLMParams, ModelConfig

            cfg.llm.model_list = [
                ModelConfig(
                    model_name="default",
                    litellm_params=LiteLLMParams(model=model_env),
                )
            ]
            logger.info(f"[Config] Using MODEL env var: {model_env}")
        else:
            # Priority 2: Auto-detect from env keys and authenticated CLI providers
            from markitai.cli.providers_detect import (
                detect_all_providers,
                providers_to_model_configs,
            )

            detected = detect_all_providers()
            if detected:
                cfg.llm.model_list = providers_to_model_configs(detected)
                names = [d.model for d in detected]
                logger.info(
                    f"[Config] Auto-detected {len(detected)} provider(s): "
                    + ", ".join(names)
                )
            else:
                logger.warning(
                    "[Config] LLM enabled but no models configured. "
                    "Set MODEL env var or add models to llm.model_list in config file."
                )
    elif cfg.llm.enabled and cfg.llm.model_list:
        model_names = [m.litellm_params.model for m in cfg.llm.model_list]
        unique_models = set(model_names)
        logger.debug(
            f"[Config] LLM models configured: {len(model_names)} entries, "
            f"{len(unique_models)} unique models"
        )

    # Store handler ID, log file path and verbose in context for batch processing
    ctx.obj["_console_handler_id"] = console_handler_id
    ctx.obj["_log_file_path"] = log_file_path
    ctx.obj["_verbose"] = verbose

    # Apply preset first (if specified)
    from markitai.config import get_preset

    if preset:
        preset = preset.lower()
        preset_config = get_preset(preset, cfg)
        if preset_config:
            # Apply preset values as base
            cfg.llm.enabled = preset_config.llm
            cfg.image.alt_enabled = preset_config.alt
            cfg.image.desc_enabled = preset_config.desc
            cfg.ocr.enabled = preset_config.ocr
            cfg.screenshot.enabled = preset_config.screenshot
            logger.debug(f"Applied preset: {preset}")
        else:
            from markitai.config import BUILTIN_PRESETS

            builtin = ", ".join(sorted(BUILTIN_PRESETS))
            custom = ", ".join(sorted(cfg.presets)) if cfg.presets else ""
            available = builtin + (f", {custom}" if custom else "")
            console.print(
                f"[red]Error: Unknown preset '{preset}'. Available: {available}[/red]"
            )
            raise SystemExit(1)

    # Override with explicit CLI options (--flag or --no-flag)
    # None means not specified, so we don't override
    if llm is not None:
        cfg.llm.enabled = llm
    if alt is not None:
        cfg.image.alt_enabled = alt
    if desc is not None:
        cfg.image.desc_enabled = desc
    if ocr is not None:
        cfg.ocr.enabled = ocr
    if screenshot is not None:
        cfg.screenshot.enabled = screenshot
    if screenshot_only:
        # screenshot_only enables screenshot capture but NOT implicitly LLM
        # --screenshot-only alone: just capture screenshots (no .md output)
        # --llm --screenshot-only: capture + LLM extraction
        cfg.screenshot.screenshot_only = True
        cfg.screenshot.enabled = True  # Implicitly enable screenshot
    if no_compress:
        cfg.image.compress = False
    if no_cache:
        cfg.cache.no_cache = True
    if no_cache_for:
        # Parse comma-separated patterns
        cfg.cache.no_cache_patterns = [
            p.strip() for p in no_cache_for.split(",") if p.strip()
        ]
    if batch_concurrency is not None:
        cfg.batch.concurrency = batch_concurrency
    if url_concurrency is not None:
        cfg.batch.url_concurrency = url_concurrency
    if llm_concurrency is not None:
        cfg.llm.concurrency = llm_concurrency
    if max_depth is not None:
        cfg.batch.scan_max_depth = max_depth

    if pure:
        cfg.llm.pure = True

    if keep_base:
        cfg.llm.keep_base = True

    # Env var support for pure mode
    if not pure and os.environ.get("MARKITAI_PURE", "").strip() in ("1", "true", "yes"):
        cfg.llm.pure = True

    # Warn about features that --pure silently overrides
    if cfg.llm.pure and cfg.llm.enabled:
        ignored_flags = []
        if cfg.image.alt_enabled:
            ignored_flags.append("--alt")
        if cfg.image.desc_enabled:
            ignored_flags.append("--desc")
        if cfg.screenshot.enabled and not cfg.screenshot.screenshot_only:
            ignored_flags.append("--screenshot")
        if ignored_flags:
            flags_str = ", ".join(ignored_flags)
            console.print(
                f"[yellow]Warning: --pure mode ignores {flags_str} "
                f"(pure mode only does text cleaning)[/yellow]"
            )

    # Validate vision model configuration if image analysis is enabled
    _check_vision_model_config(cfg, console, verbose)

    # Validate local provider dependencies (claude-agent, copilot)
    if cfg.llm.model_list:
        from markitai.providers import (
            check_deprecated_models,
            validate_local_provider_deps,
        )

        models = [m.litellm_params.model for m in cfg.llm.model_list]
        dep_warnings = validate_local_provider_deps(models)
        deprecation_warnings = check_deprecated_models(models)
        all_warnings = dep_warnings + deprecation_warnings
        if all_warnings:
            for warning in all_warnings:
                console.print(f"[yellow]{warning}[/yellow]")
            console.print()

    # Validate fetch strategy flags (mutually exclusive)
    strategy_flags = sum(
        [use_defuddle, use_playwright, use_static, use_jina, use_cloudflare]
    )
    if strategy_flags > 1:
        console.print(
            "[red]Error: --defuddle, --playwright, --static, --jina, and --cloudflare are mutually exclusive.[/red]"
        )
        ctx.exit(1)

    # Determine fetch strategy
    from markitai.fetch import FetchStrategy

    if use_defuddle:
        fetch_strategy = FetchStrategy.DEFUDDLE
        explicit_fetch_strategy = True
    elif use_static:
        fetch_strategy = FetchStrategy.STATIC
        explicit_fetch_strategy = True
    elif use_playwright:
        fetch_strategy = FetchStrategy.PLAYWRIGHT
        explicit_fetch_strategy = True
    elif use_jina:
        fetch_strategy = FetchStrategy.JINA
        explicit_fetch_strategy = True
    elif use_cloudflare:
        fetch_strategy = FetchStrategy.CLOUDFLARE
        explicit_fetch_strategy = True
        # Also enable CF Workers AI toMarkdown for file conversion
        cfg.fetch.cloudflare.convert_enabled = True
    else:
        # Use config default or auto
        fetch_strategy = FetchStrategy(cfg.fetch.strategy)
        explicit_fetch_strategy = False

    # --kreuzberg overrides file conversion (orthogonal to fetch strategy)
    if use_kreuzberg:
        if use_cloudflare:
            console.print(
                "[red]Error: --kreuzberg and --cloudflare are mutually exclusive "
                "(both override file conversion).[/red]"
            )
            ctx.exit(1)
        cfg.fetch.kreuzberg_convert_enabled = True

    # Log input info
    if is_url_list_mode:
        logger.debug(f"Processing URL list: {len(url_entries)} URLs")
    elif is_url_input:
        logger.debug(f"Processing URL: {input_path_str}")
    else:
        assert input_path is not None  # Already validated above
        logger.debug(f"Processing: {input_path.resolve()}")
    if output:
        logger.debug(f"Output directory: {output.resolve()}")

    async def run_workflow() -> None:
        # Create OutputManager for stderr line tracking
        # Only enabled in stdout mode (when we need to erase before printing content)
        from markitai.cli.output_manager import OutputManager

        om = OutputManager(enabled=is_stdout_mode and not verbose and not quiet)

        # Helper to get effective output directory (CLI -o or config fallback)
        def get_effective_output() -> Path | None:
            if output is not None:
                return output
            if cfg.output.dir:
                logger.debug(f"[Config] Using output.dir from config: {cfg.output.dir}")
                return Path(cfg.output.dir).expanduser()
            return None

        # ── Phase 1: Input mode detection and parameter validation ──
        # Validate output directory requirements BEFORE auth preflight so that
        # simple parameter errors (missing -o) are caught immediately without
        # triggering slow network/auth activity.

        # Determine effective output for modes that require it
        effective_output: Path | None = None

        if is_url_list_mode:
            effective_output = get_effective_output()
            if effective_output is None:
                console.print(
                    "[red]Error: URL list mode requires -o/--output directory.[/red]"
                )
                ctx.exit(1)
        elif is_url_input:
            # Single URL: output is optional (None means stdout, like single file mode)
            effective_output = get_effective_output()
        elif input_path is not None and input_path.is_dir():
            effective_output = get_effective_output()
            if effective_output is None:
                console.print(
                    "[red]Error: Batch mode requires -o/--output directory.[/red]"
                )
                ctx.exit(1)

        # ── Phase 2: Pre-flight auth check ──
        # Now that parameter validation passed, check auth for local providers.
        # Skip when dry-run: no network operations needed for preview.
        if cfg.llm.enabled and cfg.llm.model_list and not dry_run:
            from rich.markup import escape

            from markitai.providers import preflight_auth_check
            from markitai.providers.auth import (
                attempt_login,
                can_attempt_login,
                get_auth_resolution_hint,
            )

            auth_results = await preflight_auth_check(cfg.llm.model_list)
            is_interactive = sys.stderr.isatty()

            # Collect auth result summaries — these survive intermediate erasure
            auth_summaries: list[str] = []

            # Auth output goes directly to stderr console — must be
            # visible in all modes (stdout, verbose, quiet).
            stderr_console = get_stderr_console()

            for status in auth_results:
                if status.authenticated:
                    continue

                stderr_console.print(
                    f"[yellow]  ! {status.provider}:"
                    f" {escape(status.error or '')}[/yellow]"
                )

                if is_interactive and can_attempt_login(status.provider):
                    try:
                        response = click.prompt(
                            f"    Login to {status.provider} now?",
                            type=click.Choice(["y", "n"], case_sensitive=False),
                            default="y",
                            err=True,
                        )
                        if response.lower() == "y":
                            login_result = await attempt_login(
                                status.provider, output_manager=om
                            )
                            if login_result.authenticated:
                                auth_summaries.append(
                                    f"  [green]✓[/green] {status.provider}"
                                    f" authenticated as {login_result.user}"
                                )
                            else:
                                auth_summaries.append(
                                    f"  [red]✗[/red] {status.provider}:"
                                    f" {escape(login_result.error or '')}"
                                )
                    except (EOFError, KeyboardInterrupt):
                        stderr_console.print("")
                else:
                    hint = get_auth_resolution_hint(status.provider)
                    stderr_console.print(f"    [dim]{hint}[/dim]")

            for summary in auth_summaries:
                stderr_console.print(summary)

        # ── Phase 3: Dispatch to appropriate processor ──

        # URL list batch mode
        if is_url_list_mode:
            assert effective_output is not None  # Validated in Phase 1
            from markitai.cli.processors.url import process_url_batch

            await process_url_batch(
                url_entries,
                effective_output,
                cfg,
                dry_run,
                verbose,
                log_file_path,
                console_handler_id=console_handler_id,
                concurrency=cfg.batch.url_concurrency,
                fetch_strategy=fetch_strategy,
                explicit_fetch_strategy=explicit_fetch_strategy,
            )
            return

        # Single URL mode — output is optional (None means stdout, like single file mode)
        # Note: We pass `output` (CLI arg) not `effective_output` (which includes
        # config fallback). This matches single file mode behavior (line ~805).
        if is_url_input:
            assert input_path_str is not None  # Guaranteed when is_url_input is True
            from markitai.cli.processors.url import process_url

            await process_url(
                input_path_str,
                output,
                cfg,
                dry_run,
                verbose,
                log_file_path,
                fetch_strategy=fetch_strategy,
                explicit_fetch_strategy=explicit_fetch_strategy,
                output_manager=om,
            )
            return

        # File/directory mode
        assert input_path is not None  # Already validated above

        # Directory batch mode
        if input_path.is_dir():
            assert effective_output is not None  # Validated in Phase 1
            from markitai.cli.processors.batch import process_batch

            await process_batch(
                input_path,
                effective_output,
                cfg,
                resume,
                dry_run,
                verbose=verbose,
                console_handler_id=console_handler_id,
                log_file_path=log_file_path,
                fetch_strategy=fetch_strategy,
                explicit_fetch_strategy=explicit_fetch_strategy,
                glob_patterns=glob_patterns,
            )
            return

        # Single file mode - output is optional (None means stdout)
        # Note: For single file mode, we do NOT use config output.dir as fallback
        # because the design specifies that no -o means stdout output
        from markitai.cli.processors.file import process_single_file

        await process_single_file(
            input_path,
            output,
            cfg,
            dry_run,
            log_file_path,
            verbose=verbose,
            quiet=quiet,
            output_manager=om,
        )

    async def run_workflow_with_cleanup() -> None:
        """Run workflow with explicit resource cleanup on exit."""
        from markitai.fetch import close_shared_clients

        try:
            await run_workflow()
        finally:
            # Cleanup shared resources
            await close_shared_clients()  # Close httpx.AsyncClient for Jina
            shutdown_converter_executor()  # Shutdown ThreadPoolExecutor

            # Close LiteLLM's aiohttp sessions to prevent "Unclosed connection" warning
            try:
                from litellm.llms.custom_httpx.async_client_cleanup import (
                    close_litellm_async_clients,
                )

                await close_litellm_async_clients()
            except Exception as e:
                logger.debug("[Cleanup] LiteLLM client cleanup failed: {}", e)

    try:
        asyncio.run(run_workflow_with_cleanup())
    except EnvVarNotFoundError as e:
        console.print(f"[red]Error: {e}[/red]")
        ctx.exit(1)
    except ValueError as e:
        error_msg = str(e)
        if (
            "missing environment variable" in error_msg
            or "No available models" in error_msg
        ):
            console.print(f"[red]Error: {error_msg}[/red]")
            ctx.exit(1)
        raise


# =============================================================================
if __name__ == "__main__":
    app()
