"""
extractor CLI — built with typer.

Commands:
  run       Extract a file or directory to JSONL (streaming, progress bars)
  view      Pretty-print a JSONL output file in the terminal
  validate  Validate a JSONL output file against the schema
  info      Show detected format info and parser availability
  schema    Print element types or full JSON Schema
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .cache import ManifestCache

import contextlib

import typer
from rich.console import Console
from rich.panel import Panel
from rich.progress import (
    BarColumn,
    MofNCompleteColumn,
    Progress,
    SpinnerColumn,
    TextColumn,
    TimeElapsedColumn,
)
from rich.table import Table
from rich.text import Text

from . import __version__
from .errors import ExtractorError
from .exit_codes import ExitCode
from .schema import ELEMENT_TYPES, SCHEMA_VERSION

# ---------------------------------------------------------------------------
# Remote URI helpers
# ---------------------------------------------------------------------------


def _is_remote_uri(target: str) -> bool:
    return "://" in target and not target.startswith("/")


def _collect_remote_files(uri: str, connector: object, glob_pattern: str | None) -> list[object]:
    from .sources.base import BaseSource

    assert isinstance(connector, BaseSource)
    return list(connector.list(uri, glob_pattern))


def _version_callback(value: bool) -> None:
    if value:
        from . import __version__
        from .schema import SCHEMA_VERSION

        print(f"extractor {__version__}  schema {SCHEMA_VERSION}")
        raise typer.Exit()


app = typer.Typer(
    name="extractor",
    help="Production-grade document extraction to versioned RAG-ready JSONL.\n\n"
    "Quick start:\n\n"
    "  extractor run paper.pdf                          # stream chunks to stdout\n\n"
    "  extractor run ./docs/ --out ./out/               # directory → files\n\n"
    "  extractor run s3://bucket/docs/ --sink mongodb --sink-uri mongodb://localhost:27017\n\n"
    "Docs: https://github.com/coresdk-io/extractor",
    add_completion=False,
    no_args_is_help=True,
)
console = Console(stderr=True)
out_console = Console(highlight=False)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _error_panel(title: str, message: str, exit_code: int) -> None:
    console.print(
        Panel(
            f"{message}\n\n[dim]Exit code: {exit_code}[/dim]",
            title=f"[red bold]{title}[/red bold]",
            border_style="red",
        )
    )


def _version_header() -> None:
    console.print(
        f"[bold cyan]extractor[/bold cyan] [dim]v{__version__} · schema {SCHEMA_VERSION}[/dim]"
    )


def _collect_files(target: Path, exclude_dir: Path | None = None) -> list[Path]:
    if target.is_dir():
        resolved_exclude = exclude_dir.resolve() if exclude_dir is not None else None
        results = []
        for f in target.rglob("*"):
            if f.is_symlink():
                continue
            if not f.is_file():
                continue
            # Skip files inside the output directory
            if resolved_exclude is not None and f.resolve().is_relative_to(resolved_exclude):
                continue
            # Skip extractor output files
            name = f.name
            if f.suffix == ".tmp" or f.suffix == ".jsonl" or name.endswith(".manifest.json"):
                continue
            results.append(f)
        return sorted(results)
    return [target]


def _run_one(
    file_path: Path,
    *,
    mode: str,
    out_path: Path | None,
    chunk_size: int,
    overlap: int,
    strategy: str,
    include_list: list[str] | None,
    exclude_list: list[str] | None,
    tokenizer: str,
    inject_context_prefix: bool = False,
    incremental: bool = False,
    cache: ManifestCache | None = None,
    parent_size: int = 512,
    child_size: int = 128,
    entities: bool = True,
    entities_model: str = "fastino/gliner2-base-v1",
    entities_types: list[str] | None = None,
    entities_threshold: float = 0.50,
    canonicalize: bool = False,
    registry_path: str | None = None,
    use_embeddings: bool = False,
    include_full_path: bool = False,
    sink: str | None = None,
    sink_uri: str | None = None,
    sink_database: str = "extractor",
    sink_table: str = "elements",
    sink_batch: int = 1000,
    classify_labels: list[str] | None = None,
    relation_types: list[str] | None = None,
    extract_schema: dict | None = None,
    extract_on_types: list[str] | None = None,
    emit_quality: bool = False,
    table_text_mode: str = "markdown",
    quality_gates_config: dict | None = None,
    figures_dir: Path | None = None,
) -> tuple[int, int]:
    """Extract one file. Returns (record_count, warning_count)."""
    from . import pipeline
    from . import quarantine as q
    from .cache import CacheEntry
    from .cache import run_config_hash as _rc_hash
    from .classifier import classify
    from .pipeline import _sha256_file
    from .schema import RunConfig
    from .telemetry import record_document_failed, record_document_processed
    from .writer import OutputWriter

    detected_mime = q.run(file_path)
    mime_key, subtype = classify(file_path, detected_mime)
    source_sha256 = _sha256_file(file_path)

    run_config = RunConfig(
        mode=mode,
        chunk_size=chunk_size,
        overlap=overlap,
        strategy=strategy,
        tokenizer=tokenizer,
        include_types=include_list,
        exclude_types=exclude_list,
        inject_context_prefix=inject_context_prefix,
        parent_size=parent_size,
        child_size=child_size,
        extract_entities=entities,
        entities_model=entities_model,
        entities_labels=entities_types,
        entities_threshold=entities_threshold,
        table_text_mode=table_text_mode,
    )

    # --- Incremental cache check ---
    if incremental and cache is not None and out_path is not None:
        rc_hash = _rc_hash(run_config)
        entry = cache.get(source_sha256, rc_hash)
        if entry is not None:
            cache_dir = cache.cache_path.parent
            jsonl_ok = (cache_dir / entry.output_jsonl).exists()
            manifest_ok = (cache_dir / entry.manifest_path).exists()
            if jsonl_ok and manifest_ok:
                console.print(f"[dim][cache hit][/dim] {file_path.name}")
                # Return zeros as a sentinel — caller treats this as a successful skip
                return (0, 0)

    writer = OutputWriter(
        source_path=file_path,
        run_config=run_config,
        out_path=out_path,
        extractor_version=__version__,
        source_sha256=source_sha256,
        include_full_path=include_full_path,
    )
    writer.begin(mime_type=mime_key)

    # Initialize database sink if requested
    _db_sink = None
    if sink:
        from .sinks import get_sink

        try:
            sink_kwargs: dict = {"batch_size": sink_batch}
            # Map CLI flags to sink constructor kwargs
            if sink in ("clickhouse",):
                _host = "localhost"
                _port = 8123
                if sink_uri:
                    import contextlib as _cl

                    _raw = sink_uri.replace("http://", "").replace("https://", "")
                    _parts = _raw.split(":")
                    _host = _parts[0]
                    if len(_parts) > 1:
                        with _cl.suppress(ValueError):
                            _port = int(_parts[1].split("/")[0])
                sink_kwargs.update(
                    {"host": _host, "port": _port, "database": sink_database, "table": sink_table}
                )
            elif sink in ("mongodb",):
                sink_kwargs.update(
                    {
                        "uri": sink_uri or "mongodb://localhost:27017",
                        "database": sink_database,
                        "collection": sink_table,
                    }
                )
            elif sink in ("postgres", "postgresql"):
                if sink_uri:
                    sink_kwargs["dsn"] = sink_uri
                sink_kwargs["table"] = sink_table
            elif sink in ("elasticsearch", "es"):
                if sink_uri:
                    sink_kwargs["url"] = sink_uri
                sink_kwargs["index"] = sink_table
            elif sink in ("qdrant",):
                if sink_uri:
                    sink_kwargs["url"] = sink_uri
                sink_kwargs["collection"] = sink_table
            elif sink in ("weaviate",):
                if sink_uri:
                    sink_kwargs["url"] = sink_uri
                sink_kwargs["class_name"] = sink_table
            elif sink in ("kafka",):
                if sink_uri:
                    sink_kwargs["brokers"] = sink_uri
                sink_kwargs["topic"] = sink_table
            elif sink in ("webhook", "http_post"):
                if sink_uri:
                    sink_kwargs["url"] = sink_uri
            _db_sink = get_sink(sink, **sink_kwargs)
            _db_sink.connect()
        except ImportError as e:
            console.print(f"[yellow]Sink warning:[/yellow] {e}")

    manifest = None
    _ner_step_out: list = []
    try:
        dual_degenerate = False
        dual_chunk_count = 0
        for element in pipeline.run(
            path=file_path,
            mode=mode,
            chunk_size=chunk_size,
            overlap=overlap,
            strategy=strategy,
            include_types=include_list,
            exclude_types=exclude_list,
            tokenizer=tokenizer,
            inject_context_prefix=inject_context_prefix,
            parent_size=parent_size,
            child_size=child_size,
            entities=entities,
            entities_model=entities_model,
            entities_types=entities_types,
            entities_threshold=entities_threshold,
            ner_warning_callback=writer.add_warning,
            ner_step_out=_ner_step_out,
            canonicalize=canonicalize,
            registry_path=registry_path,
            use_embeddings=use_embeddings,
            classify_labels=classify_labels,
            relation_types=relation_types,
            extract_schema=extract_schema,
            extract_on_types=extract_on_types,
            emit_quality=emit_quality,
            table_text_mode=table_text_mode,
            figures_dir=figures_dir,
        ):
            if mode in ("chunks", "dual-chunks"):
                writer.write_chunk(element)  # type: ignore[arg-type]
                if mode == "dual-chunks":
                    dual_chunk_count += 1
                    from .schema import ChunkRecord as _CR

                    if (
                        isinstance(element, _CR)
                        and element.element_type == "composite:parent_chunk"
                        and element.chunk_metadata
                        and element.chunk_metadata.granularity == "single"
                    ):
                        dual_degenerate = True
            else:
                writer.write_element(element)
            if _db_sink is not None:
                _db_sink.feed(element.model_dump(exclude_none=True))

        if dual_degenerate:
            writer.add_warning(
                "W_DUAL_CHUNK_DEGENERATE",
                "Document produced only one chunk at parent size; dual-chunk output is identical to single-chunk mode",
            )

        _ner_stats = _ner_step_out[0].stats if _ner_step_out else None
        manifest = writer.finalize(status="complete", parser_chain=[], ner_stats=_ner_stats)

        # --- Quality gates ---
        if quality_gates_config:
            from .quality_gates import QualityGates

            _gates = QualityGates(quality_gates_config)
            _gate_results = _gates.evaluate(manifest.stats)
            manifest = manifest.model_copy(
                update={
                    "quality_gate_results": [
                        {
                            "name": r.name,
                            "passed": r.passed,
                            "severity": r.severity,
                            "actual": r.actual,
                            "threshold": r.threshold,
                            "message": r.message,
                        }
                        for r in _gate_results
                    ]
                }
            )
            for r in _gate_results:
                if not r.passed:
                    level = "W_QUALITY_GATE_FAIL" if r.severity == "fail" else "W_QUALITY_GATE_WARN"
                    writer.add_warning(level, r.message)
            if _gates.has_failures(_gate_results):
                _n_gate_warns = len([r for r in _gate_results if not r.passed])
                record_document_failed(format=mime_key)
                return 0, len(manifest.warnings) + _n_gate_warns
    finally:
        if manifest is None:
            # Exception in flight — clean up tmp file
            tmp_path = getattr(writer, "_tmp_path", None)
            if tmp_path is not None:
                try:
                    if hasattr(writer, "_fh") and writer._fh and not writer._fh.closed:
                        writer._fh.close()
                except Exception:
                    pass
                with contextlib.suppress(Exception):
                    Path(tmp_path).unlink(missing_ok=True)
        if _db_sink is not None:
            try:
                _db_sink.flush()
                _db_sink.close()
            except Exception:
                pass

    if out_path is not None:
        manifest_path = out_path.with_suffix(".manifest.json")
        manifest_tmp = manifest_path.with_suffix(".manifest.json.tmp")
        manifest_tmp.write_text(
            json.dumps(manifest.model_dump(exclude_none=False), indent=2, ensure_ascii=False)
        )
        manifest_tmp.rename(manifest_path)

        # --- Incremental cache store ---
        if incremental and cache is not None:
            from datetime import datetime
            from datetime import timezone as _tz

            cache_dir = cache.cache_path.parent
            rc_hash = _rc_hash(run_config)
            try:
                rel_jsonl = str(out_path.relative_to(cache_dir))
                rel_manifest = str(manifest_path.relative_to(cache_dir))
            except ValueError:
                rel_jsonl = str(out_path)
                rel_manifest = str(manifest_path)
            cache.put(
                CacheEntry(
                    source_sha256=source_sha256,
                    run_config_hash=rc_hash,
                    output_jsonl=rel_jsonl,
                    manifest_path=rel_manifest,
                    extracted_at=datetime.now(_tz.utc).isoformat(),
                )
            )
            cache.save()

    n_records = (
        manifest.stats.total_elements
        if mode not in ("chunks", "dual-chunks")
        else manifest.stats.total_chunks
    )
    record_document_processed(n_records, format=mime_key)
    return n_records, len(manifest.warnings)


# ---------------------------------------------------------------------------
# run
# ---------------------------------------------------------------------------


@app.command()
def run(
    target: str = typer.Argument(..., help="File, directory, or remote URI to extract"),
    mode: str = typer.Option(
        "chunks",
        "--mode",
        "-m",
        help="Output mode: elements, chunks, or dual-chunks [default: chunks]",
    ),
    out: Path | None = typer.Option(
        None, "--out", "-o", help="Output file or directory (default: stdout)"
    ),
    chunk_size: int = typer.Option(512, "--chunk-size", help="Maximum tokens per chunk"),
    overlap: int = typer.Option(0, "--overlap", help="Overlap tokens between chunks (default: 0)"),
    strategy: str = typer.Option(
        "fast", "--strategy", help="Extraction strategy: fast, accurate, ocr"
    ),
    include_types: str | None = typer.Option(
        None, "--include-types", help="Comma-separated element types to emit"
    ),
    exclude_types: str | None = typer.Option(
        None, "--exclude-types", help="Comma-separated element types to suppress"
    ),
    tokenizer: str = typer.Option("cl100k_base", "--tokenizer", help="Tokenizer for chunk sizing"),
    quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress progress output"),
    workers: int = typer.Option(
        1, "--workers", "-w", help="Parallel workers for directory extraction (default: 1)"
    ),
    context_prefix: bool = typer.Option(
        False,
        "--context-prefix",
        help="Prepend section breadcrumb to each chunk text before embedding",
    ),
    incremental: bool = typer.Option(
        False, "--incremental", help="Skip files unchanged since last run (requires --out)"
    ),
    parent_size: int = typer.Option(
        512,
        "--parent-size",
        show_default=True,
        help="Token budget for coarse (parent) chunks in dual-chunks mode",
    ),
    child_size: int = typer.Option(
        128,
        "--child-size",
        show_default=True,
        help="Token budget for fine (child) chunks in dual-chunks mode",
    ),
    debug: bool = typer.Option(False, "--debug", help="Show full tracebacks on errors"),
    entities: bool = typer.Option(
        False,
        "--entities/--no-entities",
        help="Run GLiNER2 entity extraction (requires: pip install 'coresdk-extractor[ner]')",
    ),
    entities_model: str = typer.Option(
        "fastino/gliner2-base-v1",
        "--entities-model",
        help="GLiNER2 model name or path (local inference)",
    ),
    entities_types: str | None = typer.Option(
        None,
        "--entities-types",
        help="Comma-separated NER label list (default: organization,person,date,location,product,technology,event,quantity)",
    ),
    entities_threshold: float = typer.Option(
        0.50,
        "--entities-threshold",
        min=0.0,
        max=1.0,
        help="Minimum confidence score for entity annotations (default: 0.50)",
    ),
    canonicalize: bool = typer.Option(
        False,
        "--canonicalize/--no-canonicalize",
        help="Cross-document entity canonicalization (requires --entities and --registry-path)",
    ),
    registry_path: str | None = typer.Option(
        None,
        "--registry-path",
        help="Path to EntityRegistry JSON file (created if not exists)",
    ),
    use_embeddings: bool = typer.Option(
        False,
        "--use-embeddings",
        help="Use sentence-transformers for ambiguous entity disambiguation (requires extractor[canon-embeddings])",
    ),
    include_full_path: bool = typer.Option(
        False,
        "--include-full-path",
        help="Store the absolute source path in output instead of just the filename",
    ),
    sink: str | None = typer.Option(
        None,
        "--sink",
        help="Database sink: clickhouse, mongodb, postgres, elasticsearch, qdrant, weaviate, kafka, webhook",
    ),
    sink_uri: str | None = typer.Option(
        None,
        "--sink-uri",
        help="Database connection URI (e.g. mongodb://localhost:27017 or localhost:8123)",
    ),
    sink_database: str = typer.Option(
        "extractor", "--sink-database", help="Database name for sink (default: extractor)"
    ),
    sink_table: str = typer.Option(
        "elements", "--sink-table", help="Table/collection name for sink (default: elements)"
    ),
    sink_batch: int = typer.Option(
        1000, "--sink-batch", help="Batch size for database writes (default: 1000)"
    ),
    classify_as: str | None = typer.Option(
        None,
        "--classify-as",
        help="Comma-separated classification labels to categorize each chunk",
    ),
    extract_relations: bool = typer.Option(
        False,
        "--relations/--no-relations",
        help="Extract (subject, predicate, object) relation triples",
    ),
    relation_types: str | None = typer.Option(
        None,
        "--relation-types",
        help="Comma-separated relation predicates (default: built-in list of 15)",
    ),
    extract_schema_file: Path | None = typer.Option(
        None,
        "--extract-schema",
        help="JSON file with schema dict for structured field extraction",
    ),
    extract_on_types: str | None = typer.Option(
        None,
        "--extract-on",
        help="Comma-separated element types for structured extraction",
    ),
    source_filter: str | None = typer.Option(
        None,
        "--source-filter",
        help="Glob pattern to filter remote files, e.g. '*.pdf'",
    ),
    source_concurrency: int = typer.Option(
        4,
        "--source-concurrency",
        help="Max parallel downloads from remote sources (default: 4)",
    ),
    source_tmp_dir: str | None = typer.Option(
        None,
        "--source-tmp-dir",
        help="Directory for temp files during remote download",
    ),
    config: Path | None = typer.Option(None, "--config", help="Path to extractor.toml config file"),
    quality: bool = typer.Option(
        False, "--quality/--no-quality", help="Emit quality scores on each chunk"
    ),
    table_text_mode: str = typer.Option(
        "markdown",
        "--table-text-mode",
        help="Table text serialization: markdown|nl-rows|nl-columns|hybrid",
    ),
    figures_dir: Path | None = typer.Option(
        None, "--figures-dir", help="Directory to export figure assets (PNG/JPEG)"
    ),
) -> None:
    """Extract a document (or directory of documents) to JSONL."""
    from .config import load_config as _load_config
    from .errors import ParserError, QuarantineError, UnsupportedFormatError

    _cfg = _load_config(config)
    _run_cfg = _cfg.get("run", {})
    # Apply config defaults (only if CLI arg is at its default value)
    if chunk_size == 512 and "chunk_size" in _run_cfg:
        chunk_size = int(_run_cfg["chunk_size"])
    if tokenizer == "cl100k_base" and "tokenizer" in _run_cfg:
        tokenizer = str(_run_cfg["tokenizer"])
    if mode == "chunks" and "mode" in _run_cfg:
        mode = str(_run_cfg["mode"])
    # sink section
    _sink_cfg = _cfg.get("sink", {})
    if sink is None and "type" in _sink_cfg:
        sink = str(_sink_cfg["type"])
    if sink_uri is None and "uri" in _sink_cfg:
        sink_uri = str(_sink_cfg["uri"])
    if sink_uri:
        from urllib.parse import urlparse as _urlparse

        _parsed = _urlparse(sink_uri)
        if _parsed.password:
            console.print(
                "[yellow]Warning:[/yellow] --sink-uri contains a plaintext password. "
                "Use an env var (e.g. ${EXTRACTOR_PG_DSN}) and reference it in .extractor.toml instead.",
                err=True,
            )
    # source section
    _src_cfg = _cfg.get("source", {})
    if source_concurrency == 4 and "concurrency" in _src_cfg:
        source_concurrency = int(_src_cfg["concurrency"])
    # ner section
    _ner_cfg = _cfg.get("ner", {})
    if _ner_cfg.get("enabled") and not entities:
        entities = True
    if entities_model == "fastino/gliner2-base-v1" and "model" in _ner_cfg:
        entities_model = str(_ner_cfg["model"])
    # quality gates section
    _quality_gates_cfg: dict | None = _cfg.get("quality_gates", None) or None

    if mode not in ("elements", "chunks", "dual-chunks"):
        console.print(f"[red]Invalid --mode {mode!r}. Choose: elements, chunks, dual-chunks[/red]")
        raise typer.Exit(1)

    if include_types and exclude_types:
        _error_panel(
            "Configuration Error",
            "--include-types and --exclude-types are mutually exclusive",
            ExitCode.CONFIG_ERROR,
        )
        raise typer.Exit(ExitCode.CONFIG_ERROR)

    include_list = [t.strip() for t in include_types.split(",")] if include_types else None
    exclude_list = [t.strip() for t in exclude_types.split(",")] if exclude_types else None

    # Parse new GLiNER2 extended params
    _classify_labels: list[str] | None = (
        [t.strip() for t in classify_as.split(",")] if classify_as else None
    )
    _relation_types: list[str] | None = None
    if extract_relations:
        _relation_types = (
            [t.strip() for t in relation_types.split(",")] if relation_types else []
        ) or None
    elif relation_types:
        _relation_types = [t.strip() for t in relation_types.split(",")]
    _extract_schema: dict | None = None
    if extract_schema_file is not None:
        import json as _json

        _extract_schema = _json.loads(extract_schema_file.read_text(encoding="utf-8"))
    _extract_on_types: list[str] | None = (
        [t.strip() for t in extract_on_types.split(",")] if extract_on_types else None
    )

    # --- Incremental cache setup ---
    _cache: ManifestCache | None = None
    if incremental:
        if out is None:
            _error_panel(
                "Configuration Error", "--incremental requires --out", ExitCode.CONFIG_ERROR
            )
            raise typer.Exit(ExitCode.CONFIG_ERROR)
        from .cache import ManifestCache as _MC

        _cache_path = out / ".extractor_cache.json"
        _cache = _MC.load(_cache_path)

    # --- Auto-set registry_path when incremental + out are both set ---
    if incremental and out is not None and not registry_path:
        registry_path = str(out / ".extractor_registry.json")
        canonicalize = True  # implicitly enable canonicalization

    # --- Detect remote URI vs local path ---
    _remote_mode = _is_remote_uri(target)
    _source_tmp = Path(source_tmp_dir) if source_tmp_dir else None

    if _remote_mode:
        from .sources.register import register_cloud_sources
        from .sources.registry import get_source

        register_cloud_sources()
        _scheme = target.split("://")[0]
        _connector = get_source(_scheme)
        _source_files = _collect_remote_files(target, _connector, source_filter)
        if not _source_files:
            _error_panel("No Files Found", f"No files found at {target}", ExitCode.CONFIG_ERROR)
            raise typer.Exit(ExitCode.CONFIG_ERROR)
    else:
        _target_path = Path(target)
        files = _collect_files(_target_path, exclude_dir=out)
        if not files:
            _error_panel("No Files Found", f"No files found at {target}", ExitCode.CONFIG_ERROR)
            raise typer.Exit(ExitCode.CONFIG_ERROR)

    if not quiet:
        _version_header()

    exit_code = ExitCode.SUCCESS
    total_records = 0
    results: list[tuple[str, int, int, str]] = []  # (name, records, warnings, status)
    if _remote_mode:
        multi = len(_source_files) > 1 or out is not None
    else:
        multi = len(files) > 1 or (out is not None and (_target_path.is_dir() or out.is_dir()))

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        MofNCompleteColumn(),
        TimeElapsedColumn(),
        console=console,
        disable=quiet or out is None,
        transient=True,
    ) as progress:
        _total_files = len(_source_files) if _remote_mode else len(files)
        task = progress.add_task("Extracting...", total=_total_files)

        def _resolve_out_path(file_path: Path) -> tuple[Path | None, bool]:
            """Return (out_path, skip). skip=True means the file should be skipped."""
            if out is None:
                return None, False
            if multi:
                out.mkdir(parents=True, exist_ok=True)
                import re as _re

                safe_stem = _re.sub(r"[/\\:]", "_", file_path.stem)
                safe_suffix = _re.sub(r"[/\\:]", "_", file_path.suffix.lstrip("."))
                op = out / (
                    safe_stem + "_" + safe_suffix + f".{mode}.jsonl"
                    if safe_suffix
                    else safe_stem + f".{mode}.jsonl"
                )
                if not op.resolve().is_relative_to(out.resolve()):
                    return None, True
                return op, False
            return out, False

        def _process_file(file_path: Path) -> tuple[Path, int, int, str, int]:
            """Run _run_one for a single file. Returns (path, n_records, n_warnings, status, exit_code_contrib)."""
            out_path, skip = _resolve_out_path(file_path)
            if skip:
                return (file_path, 0, 1, "skip", 0)
            try:
                n_records, n_warnings = _run_one(
                    file_path,
                    mode=mode,
                    out_path=out_path,
                    chunk_size=chunk_size,
                    overlap=overlap,
                    strategy=strategy,
                    include_list=include_list,
                    exclude_list=exclude_list,
                    tokenizer=tokenizer,
                    inject_context_prefix=context_prefix,
                    incremental=incremental,
                    cache=_cache,
                    parent_size=parent_size,
                    child_size=child_size,
                    entities=entities,
                    entities_model=entities_model,
                    entities_types=[t.strip() for t in entities_types.split(",")]
                    if entities_types
                    else None,
                    entities_threshold=entities_threshold,
                    canonicalize=canonicalize,
                    registry_path=registry_path,
                    use_embeddings=use_embeddings,
                    include_full_path=include_full_path,
                    sink=sink,
                    sink_uri=sink_uri,
                    sink_database=sink_database,
                    sink_table=sink_table,
                    sink_batch=sink_batch,
                    classify_labels=_classify_labels,
                    relation_types=_relation_types,
                    extract_schema=_extract_schema,
                    extract_on_types=_extract_on_types,
                    emit_quality=quality,
                    table_text_mode=table_text_mode,
                    quality_gates_config=_quality_gates_cfg,
                    figures_dir=figures_dir,
                )
                return (file_path, n_records, n_warnings, "ok", ExitCode.SUCCESS)
            except QuarantineError as e:
                if debug:
                    console.print_exception()
                _error_panel("Quarantine", f"{file_path.name}\n{e}", ExitCode.QUARANTINE)
                from .telemetry import record_document_failed as _rdf

                _rdf()
                return (file_path, 0, 1, "quarantine", ExitCode.QUARANTINE)
            except UnsupportedFormatError as e:
                if debug:
                    console.print_exception()
                _error_panel("Unsupported Format", str(e), ExitCode.UNSUPPORTED)
                from .telemetry import record_document_failed as _rdf

                _rdf()
                return (file_path, 0, 1, "unsupported", ExitCode.UNSUPPORTED)
            except ParserError as e:
                if debug:
                    console.print_exception()
                _error_panel("Parser Error", str(e), ExitCode.PARSER_CRASH)
                from .telemetry import record_document_failed as _rdf

                _rdf()
                return (file_path, 0, 1, "error", ExitCode.PARSER_CRASH)
            except ExtractorError as e:
                if debug:
                    console.print_exception()
                _error_panel("Error", str(e), e.exit_code)
                from .telemetry import record_document_failed as _rdf

                _rdf()
                return (file_path, 0, 1, "error", e.exit_code)
            except Exception as _exc:
                _log = __import__("logging").getLogger(__name__)
                _log.error("Unexpected error processing %s: %s", file_path, _exc, exc_info=debug)
                _error_panel("Unexpected Error", f"{file_path.name}\n{_exc}", ExitCode.PARSER_CRASH)
                return (file_path, 0, 1, "error", ExitCode.PARSER_CRASH)

        def _process_remote_file(source_file: object) -> tuple[str, int, int, str, int]:
            """Download, quarantine, and extract a remote SourceFile."""
            from .sources.base import SourceFile as _SF

            assert isinstance(source_file, _SF)
            from . import quarantine as _q
            from .errors import QuarantineError as _QE

            try:
                with _connector.fetch(source_file, tmp_dir=_source_tmp) as tmp_path:
                    # Security gate — same checks applied to local files
                    try:
                        _q.run(tmp_path)
                    except _QE as e:
                        if debug:
                            console.print_exception()
                        _error_panel(
                            "Quarantine",
                            f"{source_file.filename} ({source_file.uri})\n{e}",
                            ExitCode.QUARANTINE,
                        )
                        return (
                            source_file.filename,
                            0,
                            1,
                            "quarantine",
                            ExitCode.QUARANTINE,
                        )

                    out_path, skip = _resolve_out_path(Path(source_file.filename))
                    if skip:
                        return (source_file.filename, 0, 1, "skip", 0)

                    try:
                        n_records, n_warnings = _run_one(
                            tmp_path,
                            mode=mode,
                            out_path=out_path,
                            chunk_size=chunk_size,
                            overlap=overlap,
                            strategy=strategy,
                            include_list=include_list,
                            exclude_list=exclude_list,
                            tokenizer=tokenizer,
                            inject_context_prefix=context_prefix,
                            incremental=incremental,
                            cache=_cache,
                            parent_size=parent_size,
                            child_size=child_size,
                            entities=entities,
                            entities_model=entities_model,
                            entities_types=[t.strip() for t in entities_types.split(",")]
                            if entities_types
                            else None,
                            entities_threshold=entities_threshold,
                            canonicalize=canonicalize,
                            registry_path=registry_path,
                            use_embeddings=use_embeddings,
                            include_full_path=include_full_path,
                            sink=sink,
                            sink_uri=sink_uri,
                            sink_database=sink_database,
                            sink_table=sink_table,
                            sink_batch=sink_batch,
                            classify_labels=_classify_labels,
                            relation_types=_relation_types,
                            extract_schema=_extract_schema,
                            extract_on_types=_extract_on_types,
                            emit_quality=quality,
                            table_text_mode=table_text_mode,
                            figures_dir=figures_dir,
                        )
                        return (
                            source_file.filename,
                            n_records,
                            n_warnings,
                            "ok",
                            ExitCode.SUCCESS,
                        )
                    except QuarantineError as e:
                        if debug:
                            console.print_exception()
                        _error_panel(
                            "Quarantine", f"{source_file.filename}\n{e}", ExitCode.QUARANTINE
                        )
                        return (
                            source_file.filename,
                            0,
                            1,
                            "quarantine",
                            ExitCode.QUARANTINE,
                        )
                    except UnsupportedFormatError as e:
                        if debug:
                            console.print_exception()
                        _error_panel("Unsupported Format", str(e), ExitCode.UNSUPPORTED)
                        return (source_file.filename, 0, 1, "unsupported", ExitCode.UNSUPPORTED)
                    except ParserError as e:
                        if debug:
                            console.print_exception()
                        _error_panel("Parser Error", str(e), ExitCode.PARSER_CRASH)
                        return (source_file.filename, 0, 1, "error", ExitCode.PARSER_CRASH)
                    except ExtractorError as e:
                        if debug:
                            console.print_exception()
                        _error_panel("Error", str(e), e.exit_code)
                        return (source_file.filename, 0, 1, "error", e.exit_code)
            except Exception as e:
                if debug:
                    console.print_exception()
                _error_panel(
                    "Download Error",
                    f"Failed to fetch {source_file.uri}: {e}",
                    ExitCode.PARSER_CRASH,
                )
                return (source_file.filename, 0, 1, "error", ExitCode.PARSER_CRASH)

        if _remote_mode:
            import concurrent.futures as _cf

            _workers = max(1, source_concurrency)
            with _cf.ThreadPoolExecutor(max_workers=_workers) as executor:
                future_map = {executor.submit(_process_remote_file, sf): sf for sf in _source_files}
                for future in _cf.as_completed(future_map):
                    fname, n_records, n_warnings, status, ec = future.result()
                    if status == "skip":
                        progress.advance(task)
                        continue
                    progress.update(task, description=f"[cyan]{fname}[/cyan]")
                    total_records += n_records
                    if status == "ok":
                        results.append((fname, n_records, n_warnings, "ok"))
                    else:
                        results.append((fname, 0, n_warnings, status))
                    exit_code = max(exit_code, ec)
                    progress.advance(task)
        elif workers > 1 and len(files) > 1:
            import concurrent.futures as _cf

            with _cf.ThreadPoolExecutor(max_workers=workers) as executor:
                future_map = {executor.submit(_process_file, fp): fp for fp in files}
                for future in _cf.as_completed(future_map):
                    fp, n_records, n_warnings, status, ec = future.result()
                    if status == "skip":
                        progress.advance(task)
                        continue
                    progress.update(task, description=f"[cyan]{fp.name}[/cyan]")
                    total_records += n_records
                    if status == "ok":
                        results.append((fp.name, n_records, n_warnings, "ok"))
                    else:
                        results.append((fp.name, 0, n_warnings, status))
                    exit_code = max(exit_code, ec)
                    progress.advance(task)
        else:
            for file_path in files:
                out_path, skip = _resolve_out_path(file_path)
                if skip:
                    progress.advance(task)
                    continue

                progress.update(task, description=f"[cyan]{file_path.name}[/cyan]")

                fp, n_records, n_warnings, status, ec = _process_file(file_path)
                if status == "ok":
                    total_records += n_records
                    results.append((file_path.name, n_records, n_warnings, "ok"))
                else:
                    results.append((file_path.name, 0, n_warnings, status))
                exit_code = max(exit_code, ec)

                progress.advance(task)

    # --- Export entity graph after all files processed ---
    if canonicalize and registry_path:
        try:
            from .canon.registry import EntityRegistry as _ER

            _reg = _ER.load(Path(registry_path))
            _reg.export_graph(Path(registry_path).parent / "entity_graph.json")
        except Exception:
            pass

    if not quiet and out is not None:
        if len(results) > 1:
            tbl = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
            tbl.add_column("File", style="cyan", no_wrap=True)
            tbl.add_column("Records", justify="right")
            tbl.add_column("Warnings", justify="right")
            tbl.add_column("Status")
            for name, n, w, status in results:
                status_txt = "[green]ok[/green]" if status == "ok" else f"[red]{status}[/red]"
                warn_txt = f"[yellow]{w}[/yellow]" if w else "0"
                tbl.add_row(name, str(n), warn_txt, status_txt)
            console.print(tbl)
            _n_processed = len(_source_files) if _remote_mode else len(files)
            console.print(
                f"\n[bold green]Done.[/bold green] "
                f"{total_records} records from {_n_processed} file(s) → [cyan]{out}[/cyan]"
            )
        else:
            n, w = results[0][1], results[0][2]
            warn_str = f"  [yellow]{w} warning(s)[/yellow]" if w else ""
            console.print(f"[green]✓[/green] {n} records → [cyan]{out}[/cyan]{warn_str}")

    raise typer.Exit(exit_code)


# ---------------------------------------------------------------------------
# view
# ---------------------------------------------------------------------------

_TYPE_COLORS: dict[str, str] = {
    "structural:title": "bold magenta",
    "structural:section_header": "magenta",
    "text:narrative": "white",
    "text:abstract": "cyan",
    "text:admonition": "yellow",
    "text:pull_quote": "italic cyan",
    "table:simple": "green",
    "table:complex": "green",
    "code:block": "bright_blue",
    "list:item": "white",
    "list:item_ordered": "white",
    "media:figure": "bright_yellow",
    "scientific:equation_display": "bright_cyan",
    "scientific:reference_entry": "dim",
    "text:transcript_segment": "white",
    "meta:document_title": "bold white",
}


@app.command()
def view(
    jsonl_file: Path = typer.Argument(..., help="JSONL file produced by extractor run"),
    max_text: int = typer.Option(
        200, "--max-text", help="Maximum text chars to display per element"
    ),
    types: str | None = typer.Option(
        None, "--types", "-t", help="Comma-separated element types to show"
    ),
    no_meta: bool = typer.Option(False, "--no-meta", help="Hide envelope/manifest lines"),
    count: bool = typer.Option(
        False, "--count", "-n", help="Print element counts by type and exit"
    ),
) -> None:
    """Pretty-print a JSONL extraction file in the terminal."""
    if not jsonl_file.exists():
        _error_panel("File Not Found", str(jsonl_file), ExitCode.CONFIG_ERROR)
        raise typer.Exit(ExitCode.CONFIG_ERROR)

    filter_types = {t.strip() for t in types.split(",")} if types else None
    type_counts: dict[str, int] = {}

    with open(jsonl_file, encoding="utf-8") as f:
        for raw_line in f:
            raw_line = raw_line.strip()
            if not raw_line:
                continue
            try:
                obj = json.loads(raw_line)
            except json.JSONDecodeError:
                console.print(f"[red]Invalid JSON:[/red] {raw_line[:80]}")
                continue

            rec_type = obj.get("type") or obj.get("element_type", "")

            if rec_type in ("envelope", "manifest", "stream_end"):
                if not no_meta:
                    if rec_type == "envelope":
                        src = obj.get("source", {})
                        rc = obj.get("run_config", {})
                        console.print(
                            Panel(
                                f"[bold]{src.get('filename', '?')}[/bold]\n"
                                f"size: {src.get('size_bytes', 0):,} bytes  "
                                f"sha256: [dim]{src.get('sha256', '')[:16]}…[/dim]\n"
                                f"mode: [cyan]{rc.get('mode', '?')}[/cyan]  "
                                f"strategy: [cyan]{rc.get('strategy', '?')}[/cyan]  "
                                f"extractor: v{obj.get('extractor_version', '?')}",
                                title="[bold cyan]Document[/bold cyan]",
                                border_style="cyan",
                            )
                        )
                    elif rec_type == "stream_end":
                        console.print(
                            f"[dim]── stream_end  "
                            f"elements={obj.get('total_elements', 0)}  "
                            f"chunks={obj.get('total_chunks', 0)} ──[/dim]"
                        )
                continue

            etype = obj.get("element_type", "unknown")
            type_counts[etype] = type_counts.get(etype, 0) + 1

            if count:
                continue

            if filter_types and etype not in filter_types:
                continue

            text = obj.get("text", "")
            if len(text) > max_text:
                text = text[:max_text] + "…"

            color = _TYPE_COLORS.get(etype, "white")
            section_path = obj.get("section_path", [])
            breadcrumb = " › ".join(section_path) if section_path else ""
            page = obj.get("page")

            label = Text()
            label.append(f"[{etype}]", style=f"bold {color}")
            if breadcrumb:
                label.append(f"  {breadcrumb}", style="dim")
            if page:
                label.append(f"  p{page}", style="dim")

            console.print(label)

            if etype in ("structural:title", "structural:section_header"):
                lvl = obj.get("heading_level", 1)
                console.print(f"  {'#' * lvl} {text}", style=color)

            elif etype.startswith("table:"):
                tdata = obj.get("table", {}) or {}
                md = tdata.get("markdown") or text
                for tline in md.splitlines()[:6]:
                    console.print(f"  [green]{tline}[/green]")
                rows = (tdata.get("structured") or {}).get("rows", [])
                if len(rows) > 4:
                    console.print(f"  [dim]… {len(rows)} rows total[/dim]")

            elif etype == "code:block":
                lang = (obj.get("metadata") or {}).get("language") or ""
                lang_str = f"[dim]{lang}[/dim] " if lang else ""
                console.print(f"  {lang_str}[bright_blue]{text}[/bright_blue]")

            elif etype == "scientific:equation_display":
                eq = obj.get("equation", {}) or {}
                console.print(f"  [bright_cyan]{eq.get('latex') or text}[/bright_cyan]")

            elif etype == "text:transcript_segment":
                tr = obj.get("transcript", {}) or {}
                speaker = tr.get("speaker") or "?"
                t0 = tr.get("start_time_s", 0)
                t1 = tr.get("end_time_s", 0)
                console.print(f"  [bold]{speaker}[/bold] [dim]{t0:.1f}s–{t1:.1f}s[/dim]  {text}")

            elif etype == "text:admonition":
                adm = obj.get("admonition", {}) or {}
                kind = adm.get("kind", "note").upper()
                console.print(f"  [yellow bold]{kind}[/yellow bold]  {text}")

            else:
                console.print(f"  {text}", style=color)

            console.print()

    if count:
        tbl = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
        tbl.add_column("Element Type", style="cyan")
        tbl.add_column("Count", justify="right")
        for etype, n in sorted(type_counts.items(), key=lambda x: -x[1]):
            tbl.add_row(etype, str(n))
        tbl.add_row("[bold]TOTAL[/bold]", f"[bold]{sum(type_counts.values())}[/bold]")
        console.print(tbl)


# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------


@app.command()
def validate(
    jsonl_file: Path = typer.Argument(..., help="JSONL file to validate"),
    level: str = typer.Option(
        "schema", "--level", help="Validation level: basic, schema, invariants"
    ),
) -> None:
    """Validate a JSONL output file against the extractor schema."""
    from .schema import ATOMIC_TYPES, BaseElement

    if not jsonl_file.exists():
        _error_panel("File Not Found", str(jsonl_file), ExitCode.CONFIG_ERROR)
        raise typer.Exit(ExitCode.CONFIG_ERROR)

    errors = 0
    warnings = 0
    line_num = 0
    envelope_seen = False
    prev_seq = -1

    with open(jsonl_file, encoding="utf-8") as f:
        for raw_line in f:
            line_num += 1
            raw_line = raw_line.strip()
            if not raw_line:
                continue
            try:
                obj = json.loads(raw_line)
            except json.JSONDecodeError as e:
                console.print(f"[red]Line {line_num}:[/red] Invalid JSON: {e}")
                errors += 1
                continue

            rec_type = obj.get("type") or obj.get("element_type", "")

            if line_num == 1:
                if obj.get("type") == "envelope":
                    envelope_seen = True
                    continue
                else:
                    console.print(
                        f"[yellow]Warning line {line_num}:[/yellow] Expected envelope as first line"
                    )
                    warnings += 1

            if rec_type in ("stream_end", "manifest"):
                continue

            if level in ("schema", "invariants"):
                try:
                    el = BaseElement.model_validate(obj)
                except Exception as e:
                    console.print(f"[red]Line {line_num}:[/red] {e}")
                    errors += 1
                    continue

                if level == "invariants":
                    if el.sequence_index is not None:
                        if el.sequence_index <= prev_seq:
                            console.print(
                                f"[red]Line {line_num}:[/red] "
                                f"sequence_index {el.sequence_index} ≤ previous {prev_seq}"
                            )
                            errors += 1
                        prev_seq = el.sequence_index

                    if el.element_type in ATOMIC_TYPES and not el.text:
                        console.print(
                            f"[yellow]Line {line_num}:[/yellow] "
                            f"Atomic element {el.element_type!r} has empty text"
                        )
                        warnings += 1

    if not envelope_seen and level != "basic":
        console.print("[yellow]Warning:[/yellow] No envelope record found")
        warnings += 1

    if errors == 0:
        console.print(f"[green bold]✓ Valid.[/green bold] {line_num} lines  {warnings} warning(s)")
        raise typer.Exit(ExitCode.SUCCESS)
    else:
        console.print(f"[red bold]✗ Invalid.[/red bold] {errors} error(s)  {warnings} warning(s)")
        raise typer.Exit(1)


# ---------------------------------------------------------------------------
# info
# ---------------------------------------------------------------------------


@app.command()
def info(
    target: Path | None = typer.Argument(None, help="File to inspect (omit to list all parsers)"),
) -> None:
    """Show detected format info and parser availability."""
    from .parsers import register as _reg_init  # noqa: F401
    from .registry import registry as reg

    _version_header()
    console.print()

    if target:
        from . import quarantine as q
        from .classifier import classify

        try:
            mime = q.run(target)
            mime_key, subtype = classify(target, mime)
            size = target.stat().st_size

            tbl = Table(show_header=False, box=None, pad_edge=False)
            tbl.add_column("Key", style="dim", width=16)
            tbl.add_column("Value")
            tbl.add_row("File", str(target))
            tbl.add_row("Size", f"{size:,} bytes")
            tbl.add_row("Detected MIME", mime)
            tbl.add_row("Routed MIME", mime_key)
            tbl.add_row("Subtype", subtype or "—")

            try:
                parser = reg.get(mime_key, subtype)
                tbl.add_row("Parser", f"[green]{parser.parser_key}[/green]")
            except Exception:
                tbl.add_row("Parser", "[red]No parser registered[/red]")

            console.print(Panel(tbl, title=f"[bold]{target.name}[/bold]", border_style="cyan"))

        except ExtractorError as e:
            _error_panel("Error", str(e), e.exit_code)

    else:
        tbl = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
        tbl.add_column("MIME Type", style="cyan")
        tbl.add_column("Subtype")
        tbl.add_column("Parser")
        for mime_key, subtype in sorted(reg.supported_formats()):
            try:
                parser = reg.get(mime_key, subtype)
                pname = parser.__class__.__name__
            except Exception:
                pname = "—"
            tbl.add_row(mime_key, subtype or "any", pname)
        console.print(tbl)

        # Show discovered plugins
        from importlib.metadata import entry_points as _ep

        parser_plugins = list(_ep(group="extractor.parsers"))
        sink_plugins = list(_ep(group="extractor.sinks"))
        if parser_plugins or sink_plugins:
            console.print("\n[bold]Plugins[/bold]")
            for p in parser_plugins:
                console.print(f"  parser: {p.name} ({p.value})")
            for s in sink_plugins:
                console.print(f"  sink: {s.name} ({s.value})")


# ---------------------------------------------------------------------------
# schema
# ---------------------------------------------------------------------------


@app.command()
def schema(
    element_type: str | None = typer.Argument(None, help="Element type to describe"),
    json_schema: bool = typer.Option(False, "--json", help="Output JSON Schema (2020-12)"),
    schema_type: str = typer.Option(
        "element", "--type", help="Which schema to emit: element, chunk, manifest"
    ),
    out_file: Path | None = typer.Option(
        None, "--out", help="Write schema to file instead of stdout"
    ),
) -> None:
    """List element types, or print full JSON Schema."""
    if json_schema:
        from .schema import SCHEMA_VERSION, BaseElement, ChunkRecord, ManifestRecord

        model_map = {"element": BaseElement, "chunk": ChunkRecord, "manifest": ManifestRecord}
        if schema_type not in model_map:
            console.print(
                f"[red]Unknown schema type:[/red] {schema_type!r}. Choose from: element, chunk, manifest"
            )
            raise typer.Exit(1)
        model = model_map[schema_type]
        s = model.model_json_schema()
        s["$id"] = f"https://coresdk.io/schemas/v1/{schema_type}.json"
        s["$schema"] = "https://json-schema.org/draft/2020-12/schema"
        s["x-extractor-schema-version"] = SCHEMA_VERSION
        output = json.dumps(s, indent=2, ensure_ascii=False)
        if out_file:
            out_file.write_text(output + "\n", encoding="utf-8")
            console.print(f"Schema written to {out_file}")
        else:
            out_console.print(output)
        return

    if element_type is None:
        from .schema import ATOMIC_TYPES

        tbl = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
        tbl.add_column("Element Type", style="cyan")
        tbl.add_column("Category")
        tbl.add_column("Atomic", justify="center")
        for et in sorted(ELEMENT_TYPES):
            category = et.split(":")[0]
            atomic = "[green]●[/green]" if et in ATOMIC_TYPES else ""
            tbl.add_row(et, category, atomic)
        console.print(tbl)
        console.print(
            f"\n[dim]{len(ELEMENT_TYPES)} types  ● = atomic (never split across chunks)[/dim]"
        )
        return

    if element_type not in ELEMENT_TYPES:
        console.print(f"[red]Unknown element type:[/red] {element_type!r}")
        console.print("Run [bold]extractor schema[/bold] to list all types.")
        raise typer.Exit(1)

    from .schema import ATOMIC_TYPES

    console.print(f"\n[bold cyan]{element_type}[/bold cyan]")
    console.print(f"  Category : [cyan]{element_type.split(':')[0]}[/cyan]")
    console.print(
        f"  Atomic   : {'[green]yes — never split across chunks[/green]' if element_type in ATOMIC_TYPES else 'no'}"
    )


# ---------------------------------------------------------------------------
# cache
# ---------------------------------------------------------------------------

cache_cmd = typer.Typer(
    name="cache", help="Manage the incremental processing cache.", no_args_is_help=True
)
app.add_typer(cache_cmd)


@cache_cmd.command("clear")
def cache_clear(
    cache_file: str | None = typer.Argument(
        None, help="Path to .extractor_cache.json (default: ./.extractor_cache.json)"
    ),
    older_than_days: int | None = typer.Option(
        None, "--older-than", help="Only remove entries older than N days"
    ),
) -> None:
    """Clear the incremental cache."""
    from .cache import ManifestCache

    path = Path(cache_file) if cache_file else Path(".extractor_cache.json")
    if not path.exists():
        console.print("No cache file found.")
        return
    mc = ManifestCache.load(path)
    if older_than_days is not None:
        removed = mc.clear_older_than(older_than_days)
    else:
        removed = mc.clear_all()
    mc.save()
    console.print(f"Removed {removed} cache entries.")


if __name__ == "__main__":
    app()
