"""alice cite — Academic citation management command group.

Subcommands:
- ``process``: Batch-process a file of references into formatted citations.
- ``lookup``: Look up a single reference by identifier or free-text query.
- ``format``: Re-render an existing BibTeX file in a different citation format.
- ``history``: Browse and retrieve past citation sessions from the Cloud Locker.
"""

from __future__ import annotations

import json
import sys
from collections import defaultdict

import click
import httpx
import structlog
from rich.panel import Panel
from rich.table import Table

from alice_cli.auth import resolve_username
from alice_cli.cite.cache import CitationCache, is_agentcore_memory_available
from alice_cli.cite.checker import check_bibtex
from alice_cli.cite.clipboard import copy_to_clipboard
from alice_cli.cite.differ import diff_bibtex
from alice_cli.cite.formatter import format_citation, parse_bibtex, render_bibtex
from alice_cli.cite.history import filter_cite_entries
from alice_cli.cite.merger import merge_bibtex
from alice_cli.cite.models import CitationRecord, IdentifierType
from alice_cli.cite.pipeline import CitationPipeline
from alice_cli.cite.redqueen import RedQueenVerifier
from alice_cli.cite.resolver import ReferenceResolver
from alice_cli.cite.sources import (
    ArXivSource,
    CrossRefSource,
    GitHubSource,
    OpenLibrarySource,
    PubMedSource,
    ZenodoSource,
)
from alice_cli.cite.stats import compute_stats
from alice_cli.console import err_console, print_error, print_status, print_success, spinner
from alice_cli.constants import get_memory_bucket
from alice_cli.errors import AliceCLIError
from alice_cli.locker import CloudLocker
from alice_cli.models import TokenUsage
from alice_cli.session_record import build_session_record

logger = structlog.get_logger(__name__)

# Polite User-Agent for all outbound HTTP requests from cite commands.
# Includes a mailto: address so API operators (Crossref, doi.org, etc.) can
# contact us if needed — this also opts into Crossref's "polite pool" which
# is faster and less likely to trigger 429/403 rate-limiting.
_HTTP_USER_AGENT = "alice-cite/1.0 (https://github.com/jhu-alice/alice-cli; mailto:alice@jhu.edu)"
_HTTP_HEADERS = {"User-Agent": _HTTP_USER_AGENT}


# ── Helpers ───────────────────────────────────────────────────────────────────


def _build_sources(client: httpx.Client) -> dict[IdentifierType, object]:
    """Build the mapping of identifier types to data source clients."""
    crossref = CrossRefSource(client)
    return {
        IdentifierType.DOI: crossref,
        IdentifierType.ARXIV: ArXivSource(client),
        IdentifierType.PMID: PubMedSource(client),
        IdentifierType.ISBN: OpenLibrarySource(client),
        IdentifierType.GITHUB: GitHubSource(client),
        IdentifierType.ZENODO: ZenodoSource(client),
    }


def _build_pipeline(
    *,
    no_agent: bool,
    no_cache: bool,
    quiet: bool,
) -> tuple[CitationPipeline, httpx.Client]:
    """Construct the full citation pipeline with all components.

    Returns ``(pipeline, httpx_client)`` so the caller can close the client.
    """
    client = httpx.Client(timeout=30.0, headers=_HTTP_HEADERS)
    sources = _build_sources(client)
    crossref = sources[IdentifierType.DOI]

    # ── Cache ─────────────────────────────────────────────────────────
    cache: CitationCache | None = None
    if not no_cache:
        try:
            if is_agentcore_memory_available():
                cache = CitationCache()
                logger.debug("Citation cache enabled")
        except Exception:
            logger.debug("Citation cache unavailable, proceeding without cache", exc_info=True)

    # ── Resolver ──────────────────────────────────────────────────────
    resolver = ReferenceResolver(
        sources=sources,  # type: ignore[arg-type]
        default_source=crossref,  # type: ignore[arg-type]
        cache=cache,
    )

    # ── Verifier ──────────────────────────────────────────────────────
    use_strands = not no_agent
    verifier = RedQueenVerifier(
        sources=sources,  # type: ignore[arg-type]
        default_source=crossref,  # type: ignore[arg-type]
        use_strands=use_strands,
    )

    # ── Pipeline ──────────────────────────────────────────────────────
    pipeline = CitationPipeline(
        resolver=resolver,
        verifier=verifier,
        cache=cache,
    )

    return pipeline, client


def _interactive_callback(
    raw_ref: str,
    candidates: list[CitationRecord],
) -> CitationRecord | None:
    """Present candidates on stderr and prompt the user to pick one."""
    err_console.print(f"\n[bold]Multiple candidates for:[/bold] {raw_ref}")
    for i, c in enumerate(candidates, 1):
        authors = ", ".join(c.authors[:3]) or "Unknown"
        err_console.print(f"  [{i}] {c.title} — {authors} ({c.year})")
    err_console.print("  [s] Skip this reference")

    while True:
        choice = click.prompt("Select", default="1", err=True)
        if choice.lower() in ("s", "skip"):
            return None
        try:
            idx = int(choice) - 1
            if 0 <= idx < len(candidates):
                return candidates[idx]
        except ValueError:
            pass
        err_console.print("[yellow]Invalid choice, try again.[/yellow]")


def _format_output(
    results: list[tuple[CitationRecord | None, str | None]],
    references: list[str],
    *,
    strict: bool,
    quiet: bool,
) -> str:
    """Assemble final output string from pipeline results.

    In strict mode, excluded (unverified) records trigger a stderr warning.
    In non-strict mode, unverified records are annotated with a warning comment.
    """
    output_parts: list[str] = []

    for i, (record, formatted) in enumerate(results):
        if record is None or formatted is None:
            # Strict mode excluded this record, or it was skipped
            if strict and i < len(references):
                print_error(f"Excluded unverified citation: {references[i]}")
                err_console.print(
                    f"[yellow]⚠ Excluded unverified record for: {references[i]}[/yellow]"
                )
            continue

        # Non-strict: annotate unverified records
        if not strict and record.verification_status == "unverified":
            output_parts.append(
                "% WARNING: Unverified citation" f" (confidence: {record.confidence_score:.2f})"
            )

        output_parts.append(formatted)

    return "\n\n".join(output_parts)


def _persist_cite_session(
    config: object,
    *,
    subcommand: str,
    input_source: str,
    output_format: str,
    records: list[CitationRecord],
    formatted_output: str,
    quiet: bool,
) -> None:
    """Persist a cite session record to the Cloud Locker (non-fatal on failure).

    Parameters
    ----------
    config:
        The CLI config object.
    subcommand:
        "process" or "lookup".
    input_source:
        File path (process) or query text (lookup).
    output_format:
        The output format used ("bibtex", "apa", or "mla").
    records:
        The resolved CitationRecord objects.
    formatted_output:
        The full formatted citation output string.
    quiet:
        Whether to suppress status messages.
    """
    try:
        username = resolve_username(config)  # type: ignore[arg-type]
        locker = CloudLocker(
            config=config,  # type: ignore[arg-type]
            username=username,
            bucket=get_memory_bucket(config.namespace, config.environment),  # type: ignore[attr-defined]
        )

        session_type = f"cite-{subcommand}"
        metadata = {
            "subcommand": subcommand,
            "input_source": input_source,
            "output_format": output_format,
            "reference_count": len(records),
            "citation_records": [r.model_dump() for r in records],
            "formatted_output": formatted_output,
        }

        record = build_session_record(
            record_type=session_type,
            model="cite-pipeline",
            prompt=input_source,
            response=formatted_output,
            tokens=TokenUsage(input=0, output=0, total=0),
            metadata=metadata,
        )
        locker.persist(record, "cite")
        print_success("Session saved to Cloud Locker", quiet)
    except Exception as exc:
        err_console.print(f"[yellow]⚠ Could not save session: {exc}[/yellow]")


# ── Command Group ─────────────────────────────────────────────────────────────


@click.group("cite")
@click.pass_context
def cite_group(ctx: click.Context) -> None:
    """Academic citation management — resolve, verify, and format references."""
    ctx.ensure_object(dict)


# ── process subcommand ────────────────────────────────────────────────────────


@cite_group.command("process")
@click.argument("file", type=click.Path(exists=True))
@click.option("--output", "-o", type=click.Path(), default=None, help="Write output to file")
@click.option(
    "--output-format",
    type=click.Choice(["bibtex", "apa", "mla", "chicago", "ieee", "vancouver"]),
    default="bibtex",
    help="Citation format (default: bibtex)",
)
@click.option("--interactive", is_flag=True, default=False, help="Prompt for disambiguation")
@click.option("--strict", is_flag=True, default=False, help="Exclude unverified citations")
@click.option("--no-agent", is_flag=True, default=False, help="Force deterministic verification")
@click.option("--no-cache", is_flag=True, default=False, help="Bypass citation cache")
@click.option("--annotate", is_flag=True, default=False, help="Include abstract as annotation")
@click.option("--confidence", type=float, default=None, help="Minimum confidence score (0.0-1.0)")
@click.pass_context
def process_cmd(
    ctx: click.Context,
    file: str,
    output: str | None,
    output_format: str,
    interactive: bool,
    strict: bool,
    no_agent: bool,
    no_cache: bool,
    annotate: bool,
    confidence: float | None,
) -> None:
    """Process a file of references into formatted citations.

    Each non-empty line (or block separated by blank lines) is treated as a
    separate reference and run through the citation pipeline.

    \b
    Examples:
        alice cite process refs.txt
        alice cite process refs.txt --output-format apa -o bibliography.txt
        alice cite process refs.txt --strict --no-agent
    """
    config = ctx.obj.get("config")
    quiet = config.quiet if config else False

    if confidence is not None and not (0.0 <= confidence <= 1.0):
        raise AliceCLIError("--confidence must be between 0.0 and 1.0")

    # ── Read input ────────────────────────────────────────────────────
    try:
        with open(file, encoding="utf-8") as f:
            content = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    if not content.strip():
        raise AliceCLIError("Input file is empty — no references found.")

    # Split on blank lines or newlines
    references = [line.strip() for line in content.strip().split("\n") if line.strip()]
    if not references:
        raise AliceCLIError("No references found in input file.")

    print_status(f"Processing {len(references)} reference(s) from {file}", quiet)

    # ── Build pipeline ────────────────────────────────────────────────
    pipeline, client = _build_pipeline(no_agent=no_agent, no_cache=no_cache, quiet=quiet)

    try:
        on_candidates = _interactive_callback if interactive else None

        with spinner("Resolving and verifying citations…", quiet):
            results = pipeline.run_batch(
                references=references,
                output_format=output_format,
                strict=strict,
                on_candidates=on_candidates,
            )

        # ── Assemble output ───────────────────────────────────────────
        if annotate:
            results = [
                (
                    record,
                    format_citation(record, output_format, annotate=True),
                )
                if record is not None
                else (record, formatted)
                for record, formatted in results
            ]
        if confidence is not None:
            filtered_results = []
            for record, formatted in results:
                if record is not None and record.confidence_score < confidence:
                    err_console.print(
                        "[yellow]⚠ Excluded low-confidence citation"
                        f" (score: {record.confidence_score:.2f}):"
                        f" {record.title or 'Unknown'}[/yellow]"
                    )
                else:
                    filtered_results.append((record, formatted))
            results = filtered_results
        final_output = _format_output(results, references, strict=strict, quiet=quiet)

        if not final_output.strip():
            raise AliceCLIError("No citations produced — all references failed or were excluded.")

        # ── Write output ──────────────────────────────────────────────
        if output:
            try:
                with open(output, "w", encoding="utf-8") as f:
                    f.write(final_output)
                    f.write("\n")
                print_success(f"Citations written to {output}", quiet)
            except Exception as exc:
                raise AliceCLIError(f"Could not write output file: {exc}") from exc
        else:
            click.echo(final_output)

        successful = sum(1 for r, _ in results if r is not None)
        print_success(f"Processed {successful}/{len(references)} references", quiet)

        # ── Persist to Cloud Locker ───────────────────────────────────
        successful_records = [r for r, _ in results if r is not None]
        _persist_cite_session(
            config,
            subcommand="process",
            input_source=file,
            output_format=output_format,
            records=successful_records,
            formatted_output=final_output,
            quiet=quiet,
        )

    finally:
        client.close()


# ── lookup subcommand ─────────────────────────────────────────────────────────


@cite_group.command("lookup")
@click.argument("query")
@click.option(
    "--output-format",
    type=click.Choice(["bibtex", "apa", "mla", "chicago", "ieee", "vancouver"]),
    default="bibtex",
    help="Citation format (default: bibtex)",
)
@click.option("--interactive", is_flag=True, default=False, help="Prompt for disambiguation")
@click.option("--strict", is_flag=True, default=False, help="Exclude unverified citations")
@click.option("--no-agent", is_flag=True, default=False, help="Force deterministic verification")
@click.option("--no-cache", is_flag=True, default=False, help="Bypass citation cache")
@click.option("--clipboard", is_flag=True, default=False, help="Copy citation to clipboard")
@click.option("--annotate", is_flag=True, default=False, help="Include abstract as annotation")
@click.option("--confidence", type=float, default=None, help="Minimum confidence score (0.0-1.0)")
@click.pass_context
def lookup_cmd(
    ctx: click.Context,
    query: str,
    output_format: str,
    interactive: bool,
    strict: bool,
    no_agent: bool,
    no_cache: bool,
    clipboard: bool,
    annotate: bool,
    confidence: float | None,
) -> None:
    """Look up a single reference by identifier or free-text query.

    \b
    Examples:
        alice cite lookup "10.1038/s41598-023-41032-5"
        alice cite lookup "Attention is All You Need" --output-format apa
        alice cite lookup "arXiv:1706.03762" --no-agent
    """
    config = ctx.obj.get("config")
    quiet = config.quiet if config else False

    if confidence is not None and not (0.0 <= confidence <= 1.0):
        raise AliceCLIError("--confidence must be between 0.0 and 1.0")

    print_status(f"Looking up: {query}", quiet)

    pipeline, client = _build_pipeline(no_agent=no_agent, no_cache=no_cache, quiet=quiet)

    try:
        on_candidates = _interactive_callback if interactive else None

        with spinner("Resolving and verifying citation…", quiet):
            results = pipeline.run_batch(
                references=[query],
                output_format=output_format,
                strict=strict,
                on_candidates=on_candidates,
            )

        record, formatted = results[0] if results else (None, None)

        if record is None or formatted is None:
            if strict:
                raise AliceCLIError(f"No verified citation found for: {query}")
            raise AliceCLIError(f"No matching record found for: {query}")

        if confidence is not None and record.confidence_score < confidence:
            err_console.print(
                "[yellow]⚠ Citation below confidence threshold"
                f" (score: {record.confidence_score:.2f}):"
                f" {record.title or 'Unknown'}[/yellow]"
            )
            raise AliceCLIError(
                f"Citation confidence score ({record.confidence_score:.2f})"
                f" is below threshold ({confidence:.2f})"
            )

        # Re-render with annotate if requested
        if annotate:
            formatted = format_citation(record, output_format, annotate=True)

        # Non-strict: annotate unverified
        output_text = formatted
        if not strict and record.verification_status == "unverified":
            warning = f"% WARNING: Unverified citation (confidence: {record.confidence_score:.2f})"
            output_text = f"{warning}\n{formatted}"

        click.echo(output_text)
        print_success("Citation resolved", quiet)

        if clipboard:
            if copy_to_clipboard(output_text):
                err_console.print("[green]✓ Citation copied to clipboard[/green]")
            else:
                err_console.print(
                    "[yellow]⚠ Could not copy to clipboard"
                    " — clipboard utility not available[/yellow]"
                )

        # ── Persist to Cloud Locker ───────────────────────────────────
        _persist_cite_session(
            config,
            subcommand="lookup",
            input_source=query,
            output_format=output_format,
            records=[record],
            formatted_output=output_text,
            quiet=quiet,
        )

    finally:
        client.close()


# ── format subcommand ─────────────────────────────────────────────────────────


@cite_group.command("format")
@click.argument("file", type=click.Path(exists=True))
@click.option(
    "--output-format",
    type=click.Choice(["bibtex", "apa", "mla", "chicago", "ieee", "vancouver"]),
    required=True,
    help="Target citation format",
)
@click.option("--output", "-o", type=click.Path(), default=None, help="Write output to file")
@click.pass_context
def format_cmd(
    ctx: click.Context,
    file: str,
    output_format: str,
    output: str | None,
) -> None:
    """Parse a BibTeX file and re-render in the target format.

    Unparseable entries are skipped with a warning to stderr.

    \b
    Examples:
        alice cite format refs.bib --output-format apa
        alice cite format refs.bib --output-format mla -o bibliography.txt
    """
    config = ctx.obj.get("config")
    quiet = config.quiet if config else False

    # ── Read input ────────────────────────────────────────────────────
    try:
        with open(file, encoding="utf-8") as f:
            content = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    if not content.strip():
        raise AliceCLIError("Input file is empty.")

    print_status(f"Parsing BibTeX from {file}", quiet)

    # ── Parse ─────────────────────────────────────────────────────────
    records = parse_bibtex(content)

    # Check for unparseable entries by comparing raw entry count vs parsed
    raw_entry_count = content.count("@")
    if len(records) < raw_entry_count:
        skipped = raw_entry_count - len(records)
        err_console.print(
            f"[yellow]⚠ Skipped {skipped} unparseable entr{'y' if skipped == 1 else 'ies'}[/yellow]"
        )
        logger.warning("Skipped unparseable BibTeX entries", skipped=skipped, total=raw_entry_count)

    if not records:
        raise AliceCLIError("No parseable BibTeX entries found in input file.")

    # ── Re-render ─────────────────────────────────────────────────────
    formatted_parts: list[str] = []
    for record in records:
        try:
            formatted_parts.append(format_citation(record, output_format))
        except Exception as exc:
            err_console.print(
                f"[yellow]⚠ Could not format entry '{record.citation_key}': {exc}[/yellow]"
            )
            logger.warning("Format error", citation_key=record.citation_key, exc_info=True)

    if not formatted_parts:
        raise AliceCLIError("No entries could be formatted.")

    final_output = "\n\n".join(formatted_parts)

    # ── Write output ──────────────────────────────────────────────────
    if output:
        try:
            with open(output, "w", encoding="utf-8") as f:
                f.write(final_output)
                f.write("\n")
            print_success(f"Formatted citations written to {output}", quiet)
        except Exception as exc:
            raise AliceCLIError(f"Could not write output file: {exc}") from exc
    else:
        click.echo(final_output)

    print_success(f"Formatted {len(formatted_parts)} entries as {output_format.upper()}", quiet)


# ── history subcommand ────────────────────────────────────────────────────────


@cite_group.command("history")
@click.option("--since", default=None, help="Show sessions on or after this date (YYYY-MM-DD)")
@click.option("--until", default=None, help="Show sessions on or before this date (YYYY-MM-DD)")
@click.option(
    "--keyword",
    default=None,
    help="Search reference text and citation output (case-insensitive)",
)
@click.option(
    "--limit",
    default=20,
    type=int,
    help="Maximum number of results (default: 20)",
)
@click.option("--show", default=None, help="Retrieve and re-render a stored session by ID")
@click.option(
    "--export",
    "export_id",
    default=None,
    help="Export a stored session's citations by ID",
)
@click.option(
    "--output-format",
    type=click.Choice(["bibtex", "apa", "mla", "chicago", "ieee", "vancouver"]),
    default=None,
    help="Override output format when used with --show or --export",
)
@click.option("--output", "-o", type=click.Path(), default=None, help="Output file for --export")
@click.pass_context
def history_cmd(
    ctx: click.Context,
    since: str | None,
    until: str | None,
    keyword: str | None,
    limit: int,
    show: str | None,
    export_id: str | None,
    output_format: str | None,
    output: str | None,
) -> None:
    """Browse and retrieve past citation sessions.

    \b
    Examples:
        alice cite history
        alice cite history --since 2025-01-01 --keyword "machine learning"
        alice cite history --show <session-id>
        alice cite history --show <session-id> --output-format apa
        alice cite history --export <session-id> -o citations.bib
    """
    config = ctx.obj.get("config")
    quiet = config.quiet if config else False

    # ── Authenticate and build locker ─────────────────────────────────
    username = resolve_username(config)
    locker = CloudLocker(
        config=config,
        username=username,
        bucket=get_memory_bucket(config.namespace, config.environment),
    )

    # ── --show: retrieve and re-render a session ──────────────────────
    if show:
        _history_show(locker, show, output_format, quiet)
        return

    # ── --export: retrieve and write/print a session ──────────────────
    if export_id:
        _history_export(locker, export_id, output_format, output, quiet)
        return

    # ── List mode: load index, filter, display table ──────────────────
    with spinner("Loading citation history…", quiet):
        index = locker.load_index()

    filtered = filter_cite_entries(
        index.entries,
        since=since,
        until=until,
        keyword=keyword,
        limit=limit,
    )

    if not filtered:
        err_console.print("  [dim]No matching citation sessions found.[/dim]")
        return

    # ── Display Rich table ────────────────────────────────────────────
    table = Table(title="Citation Sessions", show_lines=False)
    table.add_column("Timestamp", style="cyan", no_wrap=True)
    table.add_column("Subcommand", style="magenta")
    table.add_column("References", style="green", justify="right")
    table.add_column("Format", style="yellow")
    table.add_column("Preview", style="white")

    for entry in filtered:
        # Extract subcommand from type (e.g. "cite-process" -> "process")
        subcommand = entry.type.removeprefix("cite-")
        table.add_row(
            entry.timestamp[:19],
            subcommand,
            "",  # reference count not available in index entry
            "",  # format not available in index entry
            entry.prompt_preview[:80],
        )

    err_console.print(table)
    print_success(f"Showing {len(filtered)} citation session(s)", quiet)


# ── check subcommand ──────────────────────────────────────────────────────────


@cite_group.command("check")
@click.argument("file", type=click.Path(exists=True))
@click.option(
    "--no-network",
    is_flag=True,
    default=False,
    help="Skip DOI link checks",
)
@click.option(
    "--json",
    "output_json",
    is_flag=True,
    default=False,
    help="Output structured JSON to stdout",
)
@click.pass_context
def check_cmd(
    ctx: click.Context,
    file: str,
    no_network: bool,
    output_json: bool,
) -> None:
    """Validate a BibTeX file for missing fields, duplicate keys, and broken DOIs.

    \b
    Examples:
        alice cite check refs.bib
        alice cite check refs.bib --no-network
        alice cite check refs.bib --json | jq '.errors'
    """
    # ── Read input ────────────────────────────────────────────────────
    try:
        with open(file, encoding="utf-8") as f:
            content = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    # ── Parse ─────────────────────────────────────────────────────────
    records = parse_bibtex(content)

    if not records:
        raise AliceCLIError("No parseable BibTeX entries found in input file.")

    key_to_title = {r.citation_key: r.title for r in records}

    # ── Validate ──────────────────────────────────────────────────────
    client: httpx.Client | None = None
    try:
        if not no_network:
            client = httpx.Client(timeout=30.0, headers=_HTTP_HEADERS)

        report = check_bibtex(records, client=client, no_network=no_network)
    finally:
        if client is not None:
            client.close()

    # ── JSON output (stdout) ──────────────────────────────────────────
    if output_json:

        def _serialise(issues: list) -> list:  # type: ignore[type-arg]
            out = []
            for i in issues:
                entry: dict[str, str] = {
                    "citation_key": i.citation_key,
                    "level": i.level,
                    "message": i.message,
                }
                if i.suggestion:
                    entry["suggestion"] = i.suggestion
                out.append(entry)
            return out

        click.echo(
            json.dumps(
                {
                    "total_entries": report.total_entries,
                    "error_count": report.error_count,
                    "warning_count": report.warning_count,
                    "errors": _serialise(report.errors),
                    "warnings": _serialise(report.warnings),
                },
                indent=2,
            )
        )
        if report.error_count > 0:
            sys.exit(1)
        return

    # ── Human-readable report (stderr, grouped by entry) ─────────────
    err_console.print("\n[bold]Validation Report[/bold]")
    err_console.print(f"  Total entries: {report.total_entries}")
    err_console.print(f"  Errors:        {report.error_count}")
    err_console.print(f"  Warnings:      {report.warning_count}")

    all_issues = report.errors + report.warnings
    if all_issues:
        # Group by citation key, preserving insertion order
        by_key: dict[str, list] = defaultdict(list)  # type: ignore[type-arg]
        for issue in all_issues:
            by_key[issue.citation_key].append(issue)

        for key, issues in by_key.items():
            title = key_to_title.get(key, "")
            title_snippet = f' — "{title[:55]}"' if title else ""
            err_console.print(f"\n[bold]{key}[/bold][dim]{title_snippet}[/dim]")
            for issue in issues:
                if issue.level == "error":
                    err_console.print(f"  [red]✗[/red]  {issue.message}")
                else:
                    err_console.print(f"  [yellow]⚠[/yellow]  {issue.message}")
                if issue.suggestion:
                    err_console.print(f"     [dim]→ Did you mean: {issue.suggestion}[/dim]")

    if report.error_count > 0:
        sys.exit(1)


# ── merge subcommand ──────────────────────────────────────────────────────────


@cite_group.command("merge")
@click.argument("file_a", type=click.Path(exists=True))
@click.argument("file_b", type=click.Path(exists=True))
@click.option("--output", "-o", type=click.Path(), default=None, help="Write merged output to file")
@click.pass_context
def merge_cmd(
    ctx: click.Context,
    file_a: str,
    file_b: str,
    output: str | None,
) -> None:
    """Merge two BibTeX files into one deduplicated file.

    Detects duplicates by DOI match or title similarity and retains the
    entry with more populated fields.

    \b
    Examples:
        alice cite merge refs_a.bib refs_b.bib
        alice cite merge refs_a.bib refs_b.bib -o merged.bib
    """
    # ── Read input files ──────────────────────────────────────────────
    try:
        with open(file_a, encoding="utf-8") as f:
            content_a = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    try:
        with open(file_b, encoding="utf-8") as f:
            content_b = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    # ── Parse ─────────────────────────────────────────────────────────
    records_a = parse_bibtex(content_a)
    records_b = parse_bibtex(content_b)

    # ── Merge ─────────────────────────────────────────────────────────
    result = merge_bibtex(records_a, records_b)

    # ── Render merged output ──────────────────────────────────────────
    rendered_parts = [render_bibtex(record) for record in result.merged_records]
    final_output = "\n\n".join(rendered_parts)

    # ── Write output ──────────────────────────────────────────────────
    if output:
        try:
            with open(output, "w", encoding="utf-8") as f:
                f.write(final_output)
                f.write("\n")
        except Exception as exc:
            raise AliceCLIError(f"Could not write output file: {exc}") from exc
    else:
        click.echo(final_output)

    # ── Summary to stderr ─────────────────────────────────────────────
    total_merged = len(result.merged_records)
    err_console.print("\n[bold]Merge Summary[/bold]")
    err_console.print(f"  Entries in file A: {result.file_a_count}")
    err_console.print(f"  Entries in file B: {result.file_b_count}")
    err_console.print(f"  Duplicates found: {result.duplicates_found}")
    err_console.print(f"  Total merged:     {total_merged}")


@cite_group.command("diff")
@click.argument("file_a", type=click.Path(exists=True))
@click.argument("file_b", type=click.Path(exists=True))
@click.pass_context
def diff_cmd(
    ctx: click.Context,
    file_a: str,
    file_b: str,
) -> None:
    """Compare two BibTeX files and show added, removed, and changed entries.

    Matches entries by citation key and displays a color-coded diff report.

    \b
    Examples:
        alice cite diff refs_old.bib refs_new.bib
    """
    # ── Read input files ──────────────────────────────────────────────
    try:
        with open(file_a, encoding="utf-8") as f:
            content_a = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    try:
        with open(file_b, encoding="utf-8") as f:
            content_b = f.read()
    except Exception as exc:
        raise AliceCLIError(f"Could not read file: {exc}") from exc

    # ── Parse ─────────────────────────────────────────────────────────
    records_a = parse_bibtex(content_a)
    records_b = parse_bibtex(content_b)

    # ── Diff ──────────────────────────────────────────────────────────
    report = diff_bibtex(records_a, records_b)

    if report.is_identical:
        err_console.print("Files are identical.")
        return

    # ── Display diff report to stderr ─────────────────────────────────
    if report.added:
        err_console.print("\n[bold green]Added entries:[/bold green]")
        for entry in report.added:
            err_console.print(f"  [green]+ {entry.citation_key}[/green]")

    if report.removed:
        err_console.print("\n[bold red]Removed entries:[/bold red]")
        for entry in report.removed:
            err_console.print(f"  [red]- {entry.citation_key}[/red]")

    if report.changed:
        err_console.print("\n[bold yellow]Changed entries:[/bold yellow]")
        for entry in report.changed:
            err_console.print(f"  [yellow]~ {entry.citation_key}[/yellow]")
            for change in entry.field_changes:
                err_console.print(f"    [yellow]{change.field}:[/yellow]")
                err_console.print(f"      [red]- {change.old_value}[/red]")
                err_console.print(f"      [green]+ {change.new_value}[/green]")


# ── stats subcommand ──────────────────────────────────────────────────────────


@cite_group.command("stats")
@click.option("--since", default=None, help="Include sessions on or after this date (YYYY-MM-DD)")
@click.option("--until", default=None, help="Include sessions on or before this date (YYYY-MM-DD)")
@click.pass_context
def stats_cmd(
    ctx: click.Context,
    since: str | None,
    until: str | None,
) -> None:
    """Show aggregate analytics on your citation history.

    \b
    Examples:
        alice cite stats
        alice cite stats --since 2025-01-01
        alice cite stats --since 2025-01-01 --until 2025-06-30
    """
    config = ctx.obj.get("config")
    quiet = config.quiet if config else False

    # ── Authenticate and build locker ─────────────────────────────────
    username = resolve_username(config)
    locker = CloudLocker(
        config=config,
        username=username,
        bucket=get_memory_bucket(config.namespace, config.environment),
    )

    # ── Load index and filter ─────────────────────────────────────────
    with spinner("Loading citation history…", quiet):
        index = locker.load_index()

    filtered = filter_cite_entries(
        index.entries,
        since=since,
        until=until,
        limit=0,  # no limit — we want all matching entries for stats
    )

    if not filtered:
        err_console.print("No citation history available.")
        return

    # ── Load session metadata for each entry ──────────────────────────
    session_metadata_list: list[dict[str, object]] = []
    for entry in filtered:
        try:
            session = locker.load_session(entry.id, "cite")
            metadata = getattr(session, "metadata", None) or {}
            # Include timestamp from the entry for monthly counts
            if "timestamp" not in metadata:
                metadata["timestamp"] = entry.timestamp
            session_metadata_list.append(metadata)
        except Exception:
            logger.debug("Could not load session", session_id=entry.id, exc_info=True)

    if not session_metadata_list:
        err_console.print("No citation history available.")
        return

    # ── Compute stats ─────────────────────────────────────────────────
    report = compute_stats(session_metadata_list)

    # ── Display StatsReport using Rich ────────────────────────────────
    err_console.print()
    err_console.print(
        Panel(
            f"[bold]Total sessions:[/bold] {report.total_sessions}\n"
            f"[bold]Total citations:[/bold] {report.total_citations}",
            title="Citation Statistics",
            expand=False,
        )
    )

    # Format breakdown table
    if report.format_breakdown:
        fmt_table = Table(title="Format Breakdown", show_lines=False)
        fmt_table.add_column("Format", style="cyan")
        fmt_table.add_column("Sessions", style="green", justify="right")
        for fmt, count in sorted(report.format_breakdown.items()):
            fmt_table.add_row(fmt, str(count))
        err_console.print(fmt_table)

    # Type breakdown table
    if report.type_breakdown:
        type_table = Table(title="Citation Type Breakdown", show_lines=False)
        type_table.add_column("Type", style="cyan")
        type_table.add_column("Citations", style="green", justify="right")
        for ctype, count in sorted(report.type_breakdown.items(), key=lambda x: x[1], reverse=True):
            type_table.add_row(ctype, str(count))
        err_console.print(type_table)

    # Top sources table
    if report.top_sources:
        src_table = Table(title="Top Sources", show_lines=False)
        src_table.add_column("Source", style="cyan")
        src_table.add_column("Count", style="green", justify="right")
        for source, count in report.top_sources:
            # Truncate long source identifiers for display
            display_source = source if len(source) <= 80 else source[:77] + "…"
            src_table.add_row(display_source, str(count))
        err_console.print(src_table)

    # Monthly counts table
    if report.monthly_counts:
        month_table = Table(title="Monthly Activity", show_lines=False)
        month_table.add_column("Month", style="cyan")
        month_table.add_column("Sessions", style="green", justify="right")
        for month, count in report.monthly_counts.items():
            month_table.add_row(month, str(count))
        err_console.print(month_table)


def _load_cite_session(locker: CloudLocker, session_id: str) -> object:
    """Load a cite session from the Cloud Locker.

    Tries the "cite" category. Raises AliceCLIError if not found.
    """
    try:
        return locker.load_session(session_id, "cite")
    except Exception as exc:
        raise AliceCLIError(f"Citation session '{session_id}' not found.") from exc


def _deserialize_and_render(
    session: object,
    output_format_override: str | None,
) -> str:
    """Deserialize citation_records from session metadata and re-render.

    Returns the formatted citation string.
    """
    metadata = getattr(session, "metadata", None) or {}

    if "citation_records" not in metadata:
        raise AliceCLIError(
            f"Session '{getattr(session, 'id', '?')}' is missing citation_records metadata."
        )

    # Determine output format: override or session's original
    fmt = output_format_override or metadata.get("output_format", "bibtex")

    # Deserialize CitationRecord objects from stored dicts
    raw_records = metadata["citation_records"]
    records: list[CitationRecord] = []
    for item in raw_records:
        records.append(CitationRecord(**item))

    # Re-render each record
    formatted_parts: list[str] = []
    for record in records:
        formatted_parts.append(format_citation(record, fmt))

    return "\n\n".join(formatted_parts)


def _history_show(
    locker: CloudLocker,
    session_id: str,
    output_format: str | None,
    quiet: bool,
) -> None:
    """Retrieve a session and re-render its citations to stderr."""
    session = _load_cite_session(locker, session_id)
    rendered = _deserialize_and_render(session, output_format)

    err_console.print(f"\n[bold]Session:[/bold] {getattr(session, 'id', session_id)}")
    err_console.print(f"[bold]Type:[/bold]    {getattr(session, 'type', '')}")
    err_console.print(f"[bold]Time:[/bold]    {getattr(session, 'timestamp', '')}")
    err_console.print()
    err_console.print(rendered)
    print_success("Session retrieved", quiet)


def _history_export(
    locker: CloudLocker,
    session_id: str,
    output_format: str | None,
    output: str | None,
    quiet: bool,
) -> None:
    """Retrieve a session and export its citations to file or stdout."""
    session = _load_cite_session(locker, session_id)
    rendered = _deserialize_and_render(session, output_format)

    if output:
        try:
            with open(output, "w", encoding="utf-8") as f:
                f.write(rendered)
                f.write("\n")
            print_success(f"Citations exported to {output}", quiet)
        except Exception as exc:
            raise AliceCLIError(f"Could not write output file: {exc}") from exc
    else:
        click.echo(rendered)
