"""
ui.py — interactive terminal UI for comfy-resolve

Resolution flow
---------------
After automatic resolution completes, the user is dropped into a picker loop
rather than a forced linear march through every asset.  The loop shows a
numbered table sorted by confidence (worst first) and accepts free-form
commands:

    <number>          — open the edit sub-menu for that asset
    a / accept        — accept everything as-is and proceed
    s / skip          — mark all UNKNOWN/LOW as skipped, keep the rest
    d / done          — proceed with whatever is currently set (alias of accept)
    q / quit          — abort the whole session

Within the edit sub-menu the user can:
    1  edit source    — enter a HuggingFace repo or direct URL
    2  search         — search HuggingFace + Civitai interactively
    3  skip / delete  — exclude this asset entirely
    4  keep           — leave it unchanged and go back

Search never auto-advances on failure — the user is always returned to the
sub-menu with a message so they can try again or take a different action.
"""

from __future__ import annotations

import logging
from typing import Any

import typer
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.rule import Rule
from rich.table import Table

from .downloader import DownloadResult, DownloadTask
from .resolver import Resolver
from .sources.base import ResolutionResult, ResolvedSource

logger = logging.getLogger(__name__)
console = Console()

# Confidence sort order — lower number = shown first (worst at top)
_CONF_ORDER: dict[str, int] = {
    "unknown":  0,
    "deleted":  0,
    "skipped":  1,
    "low":      2,
    "medium":   3,
    "high":     4,
    "local":    5,
}


def _conf_rank(r: ResolutionResult) -> int:
    return _CONF_ORDER.get(r.confidence, 0)


def _source_str(result: ResolutionResult, *, rich: bool = True) -> str:
    """Return a display string for the resolved source."""
    src = result.source
    if not src:
        return "[red]UNRESOLVED[/red]" if rich else "UNRESOLVED"
    if src.source_type == "local":
        s = f"Local: {src.repo}"
        return f"[green]{s}[/green]" if rich else s
    if src.source_type == "huggingface":
        return f"HuggingFace: {src.repo}"
    if src.source_type == "civitai":
        return f"Civitai: {src.repo}"
    if src.source_type == "direct_url":
        url = src.repo or src.download_url or "?"
        short = url[:60] + "…" if len(url) > 60 else url
        return f"URL: {short}"
    return src.source_type


def _conf_badge(confidence: str) -> str:
    """Return a coloured confidence badge string."""
    return {
        "high":    "[green]✓ HIGH[/green]",
        "medium":  "[yellow]? MEDIUM[/yellow]",
        "low":     "[red]! LOW[/red]",
        "skipped": "[dim]– SKIP[/dim]",
        "deleted": "[dim]– SKIP[/dim]",
    }.get(confidence, "[red]✗ UNKNOWN[/red]")


class UISession:
    """
    Interactive UI session for asset resolution and pre-download review.

    The main entry point is :meth:`run`, which displays a picker loop and
    returns the final list of confirmed :class:`ResolutionResult` objects.
    """

    def __init__(
        self,
        results: list[ResolutionResult],
        config: dict[str, Any],
    ) -> None:
        """
        Initialise the session.

        Args:
            results: Resolution results from the resolver pipeline.
            config:  comfy-resolve configuration dictionary.
        """
        self.results  = list(results)
        self.config   = config
        self.resolver = Resolver(config)

    # ── Public entry point ──────────────────────────────────────────────────

    def run(self) -> list[ResolutionResult]:
        """
        Run the interactive resolution picker.

        Displays a sortable, numbered asset table and accepts picker commands
        until the user confirms, skips all, or quits.

        Returns:
            Final list of :class:`ResolutionResult` objects with deleted /
            skipped entries removed.
        """
        # Sort: worst confidence first so problems are immediately visible
        self.results.sort(key=_conf_rank)

        console.print(Rule("[bold cyan]Asset Resolution Review[/bold cyan]"))

        while True:
            self._print_picker_table()
            self._print_picker_hint()

            raw = Prompt.ask("\nChoice", default="d").strip().lower()

            # ── Global commands ─────────────────────────────────────────────
            if raw in ("a", "accept", "d", "done", ""):
                break

            if raw in ("s", "skip"):
                self._skip_unresolved()
                console.print(
                    "[yellow]All UNKNOWN / LOW assets marked as skipped.[/yellow]"
                )
                break

            if raw in ("q", "quit"):
                console.print("[yellow]Aborted.[/yellow]")
                raise typer.Exit(code=0)

            # ── Numeric — open edit sub-menu ────────────────────────────────
            if raw.isdigit():
                idx = int(raw) - 1
                if 0 <= idx < len(self.results):
                    self.results[idx] = self._edit_menu(self.results[idx])
                    # Re-sort after any edit so the table stays ordered
                    self.results.sort(key=_conf_rank)
                else:
                    console.print(
                        f"[red]Invalid number. Enter 1–{len(self.results)}.[/red]"
                    )
                continue

            console.print(
                "[red]Unrecognised input. "
                "Enter a row number, or: accept / skip / quit[/red]"
            )

        return [
            r for r in self.results
            if r.confidence not in ("deleted", "skipped")
        ]

    # ── Picker table ────────────────────────────────────────────────────────

    def _print_picker_table(self) -> None:
        """Print the numbered asset table sorted by confidence."""
        table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
        table.add_column("#",          style="dim",     width=4,  justify="right")
        table.add_column("Asset",      style="cyan",    no_wrap=False)
        table.add_column("Type",       style="magenta")
        table.add_column("Source",     style="blue")
        table.add_column("Confidence", style="white",   justify="center")

        for i, result in enumerate(self.results, 1):
            # Truncate very long filenames for display
            name = result.input_name
            if len(name) > 55:
                name = name[:52] + "…"

            table.add_row(
                str(i),
                name,
                result.asset_type,
                _source_str(result),
                _conf_badge(result.confidence),
            )

        console.print()
        console.print(table)

        # Summary counts
        unknown = sum(
            1 for r in self.results
            if r.confidence in ("unknown",) and r.source is None
        )
        low     = sum(1 for r in self.results if r.confidence == "low")
        skipped = sum(1 for r in self.results if r.confidence in ("deleted", "skipped"))

        parts: list[str] = []
        if unknown:
            parts.append(f"[red]{unknown} unresolved[/red]")
        if low:
            parts.append(f"[yellow]{low} low-confidence[/yellow]")
        if skipped:
            parts.append(f"[dim]{skipped} skipped[/dim]")
        if parts:
            console.print("  " + "  •  ".join(parts))

    def _print_picker_hint(self) -> None:
        """Print the command hint line."""
        console.print(
            "\n[dim]"
            "[bold]<number>[/bold] edit an asset  •  "
            "[bold]accept[/bold] proceed as-is  •  "
            "[bold]skip[/bold] skip all unknown/low  •  "
            "[bold]quit[/bold] abort"
            "[/dim]"
        )

    # ── Edit sub-menu ───────────────────────────────────────────────────────

    def _edit_menu(self, result: ResolutionResult) -> ResolutionResult:
        """
        Open the per-asset edit sub-menu.

        Loops until the user confirms a choice.  Search failure returns to
        this menu rather than auto-advancing.

        Args:
            result: The current resolution result for this asset.

        Returns:
            Updated :class:`ResolutionResult`.
        """
        while True:
            console.print()
            console.print(
                Panel(
                    f"[bold cyan]{result.input_name}[/bold cyan]\n"
                    f"Type       : [magenta]{result.asset_type}[/magenta]\n"
                    f"Source     : {_source_str(result)}\n"
                    f"Confidence : {_conf_badge(result.confidence)}",
                    title="Editing asset",
                    border_style="cyan",
                )
            )

            console.print("  [bold]1[/bold]  Change source  [dim](HuggingFace repo or direct URL)[/dim]")
            console.print("  [bold]2[/bold]  Search         [dim](HuggingFace + Civitai)[/dim]")
            console.print("  [bold]3[/bold]  Skip / delete  [dim](exclude from download)[/dim]")
            console.print("  [bold]4[/bold]  Keep as-is     [dim](go back)[/dim]")

            action = Prompt.ask(
                "Action", choices=["1", "2", "3", "4"], default="4"
            )

            if action == "4":
                return result

            if action == "3":
                return self._mark_skipped(result)

            if action == "1":
                updated = self._edit_source(result)
                if updated is not result:
                    return updated
                # _edit_source returns the original on invalid input → loop

            if action == "2":
                updated = self._interactive_search(result)
                if updated is not result:
                    return updated
                # search returned nothing or user cancelled → loop back here

    # ── Source edit ─────────────────────────────────────────────────────────

    def _edit_source(self, result: ResolutionResult) -> ResolutionResult:
        """
        Prompt for a manual HuggingFace repo path or direct URL.

        Args:
            result: Current resolution result.

        Returns:
            Updated result on success, original result on invalid input.
        """
        console.print(
            "\n[dim]Enter a HuggingFace repo (e.g. [bold]user/repo[/bold]) "
            "or a direct URL, or leave blank to cancel.[/dim]"
        )
        raw = Prompt.ask("Source", default="").strip()

        if not raw:
            return result

        if raw.startswith("http://") or raw.startswith("https://"):
            filename = raw.split("/")[-1].split("?")[0] or result.input_name
            source = ResolvedSource(
                source_type="direct_url",
                repo=raw,
                file=filename,
                download_url=raw,
                sha256=None,
                file_size_bytes=None,
            )
            console.print(f"[green]✓ Set to direct URL: {raw}[/green]")
            return ResolutionResult(
                input_name=result.input_name,
                resolved_name=filename,
                source=source,
                confidence="high",
                error=None,
                asset_type=result.asset_type,
            )

        if "/" in raw:
            filename = Prompt.ask(
                "Filename in repo", default=result.input_name
            ).strip()
            source = ResolvedSource(
                source_type="huggingface",
                repo=raw,
                file=filename,
                download_url=None,
                sha256=None,
                file_size_bytes=None,
            )
            console.print(f"[green]✓ Set to HuggingFace: {raw}/{filename}[/green]")
            return ResolutionResult(
                input_name=result.input_name,
                resolved_name=filename,
                source=source,
                confidence="high",
                error=None,
                asset_type=result.asset_type,
            )

        console.print(
            "[red]Invalid format. "
            "Use 'user/repo' for HuggingFace or a full https:// URL.[/red]"
        )
        return result

    # ── Interactive search ──────────────────────────────────────────────────

    def _interactive_search(self, result: ResolutionResult) -> ResolutionResult:
        """
        Search HuggingFace and Civitai interactively.

        The user can refine the search term and retry as many times as they
        like.  Cancelling returns the original result unchanged so the caller
        can loop back to the edit sub-menu.

        Args:
            result: Current resolution result.

        Returns:
            Selected result on success, original result if cancelled.
        """
        search_term = Prompt.ask(
            "Search term", default=result.input_name
        ).strip()

        if not search_term:
            return result

        candidates: list[ResolutionResult] = []

        # HuggingFace
        console.print("[cyan]Searching HuggingFace…[/cyan]", end=" ")
        try:
            hf = [
                r for r in self.resolver.hf_source.search(
                    search_term, result.asset_type
                )
                if r.source is not None
            ]
            console.print(
                f"[green]{len(hf)} result(s)[/green]" if hf else "[dim]none[/dim]"
            )
            candidates.extend(hf[:5])
        except Exception as exc:
            console.print(f"[red]error: {exc}[/red]")

        # Civitai
        console.print("[cyan]Searching Civitai…[/cyan]", end=" ")
        try:
            cv = [
                r for r in self.resolver.civitai_source.search(
                    search_term, result.asset_type
                )
                if r.source is not None
            ]
            console.print(
                f"[green]{len(cv)} result(s)[/green]" if cv else "[dim]none[/dim]"
            )
            candidates.extend(cv[:5])
        except Exception as exc:
            console.print(f"[red]error: {exc}[/red]")

        if not candidates:
            console.print(
                Panel(
                    f"No results found for [bold]{search_term!r}[/bold].\n"
                    "Try a shorter or different search term.",
                    border_style="yellow",
                )
            )
            return result   # caller will loop back to edit menu

        # Display candidates
        console.print()
        table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
        table.add_column("#",     style="dim",    width=3, justify="right")
        table.add_column("File",  style="cyan",   no_wrap=False)
        table.add_column("Repo",  style="blue")
        table.add_column("Via",   style="magenta")
        table.add_column("Conf",  style="white",  justify="center")

        for i, c in enumerate(candidates, 1):
            src = c.source
            repo  = src.repo  if src else "—"
            fname = src.file  if src else c.resolved_name
            via   = src.source_type if src else "—"
            table.add_row(str(i), fname, repo, via, _conf_badge(c.confidence))

        console.print(table)

        choices = [str(i) for i in range(len(candidates) + 1)]
        choice = Prompt.ask(
            "Select (0 to cancel)", choices=choices, default="0"
        )

        if choice == "0":
            return result

        selected = candidates[int(choice) - 1]
        # Carry asset_type forward in case the search result lost it
        selected.asset_type = result.asset_type
        console.print(
            f"[green]✓ Selected: "
            f"{selected.source.repo if selected.source else '?'}"
            f"[/green]"
        )
        return selected

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

    def _mark_skipped(self, result: ResolutionResult) -> ResolutionResult:
        """Return a copy of *result* marked as skipped."""
        return ResolutionResult(
            input_name=result.input_name,
            resolved_name=result.resolved_name,
            source=None,
            confidence="skipped",
            error="Skipped by user",
            asset_type=result.asset_type,
        )

    def _skip_unresolved(self) -> None:
        """Mark all UNKNOWN and LOW confidence results as skipped in-place."""
        for i, r in enumerate(self.results):
            if r.confidence in ("unknown", "low") or r.source is None:
                self.results[i] = self._mark_skipped(r)

    # ── Pre-download review ─────────────────────────────────────────────────

    def prompt_pre_download_review(
        self, tasks: list[DownloadTask]
    ) -> list[DownloadTask]:
        """
        Show the pre-download review table and allow last-minute edits.

        The loop mirrors the resolution picker: numbered rows, edit by number,
        ``go`` to confirm, ``quit`` to abort.

        Args:
            tasks: Full list of download tasks (including local/already-present).

        Returns:
            Filtered list containing only tasks that should be downloaded
            (local and skipped entries excluded).
        """
        while True:
            console.print()
            console.print(Rule("[bold cyan]Pre-Download Review[/bold cyan]"))

            table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
            table.add_column("#",          style="dim",     width=4,  justify="right")
            table.add_column("Asset",      style="cyan",    no_wrap=False)
            table.add_column("Type",       style="magenta")
            table.add_column("Source",     style="blue")
            table.add_column("Size",       style="yellow",  justify="right")
            table.add_column("Destination",style="dim")
            table.add_column("Action",     style="white")

            for i, task in enumerate(tasks, 1):
                src = task.result.source
                name = task.result.input_name
                if len(name) > 50:
                    name = name[:47] + "…"

                if src and src.source_type == "local":
                    source_disp = f"[dim]Local: {src.repo}[/dim]"
                    action_disp = "[dim]already local[/dim]"
                elif task.result.confidence in ("skipped", "deleted") or not src:
                    source_disp = "[dim]—[/dim]"
                    action_disp = "[dim]skip[/dim]"
                else:
                    source_disp = _source_str(task.result)
                    action_disp = "[green]download[/green]"

                size_str = (
                    self._format_bytes(src.file_size_bytes)
                    if src and src.file_size_bytes
                    else "—"
                )

                table.add_row(
                    str(i),
                    name,
                    task.asset_type,
                    source_disp,
                    size_str,
                    str(task.destination),
                    action_disp,
                )

            console.print(table)
            console.print(
                "\n[dim]"
                "[bold]go[/bold] — start downloads  •  "
                "[bold]<number>[/bold] — edit entry  •  "
                "[bold]all[/bold] — bulk action on every row  •  "
                "[bold]quit[/bold] — abort"
                "[/dim]"
            )

            choice = Prompt.ask("Choice", default="go").strip().lower()

            if choice in ("q", "quit"):
                raise typer.Exit(code=0)

            if choice in ("go", "g", ""):
                final = [
                    t for t in tasks
                    if t.result.source
                    and t.result.source.source_type != "local"
                    and t.result.confidence not in ("skipped", "deleted")
                ]
                if not final:
                    console.print("[yellow]Nothing to download.[/yellow]")
                    return []
                return final

            if choice in ("all", "a"):
                tasks = self._bulk_action(tasks)
                continue

            if choice.isdigit():
                idx = int(choice) - 1
                if 0 <= idx < len(tasks):
                    tasks[idx] = self._edit_task(tasks[idx])
                else:
                    console.print(
                        f"[red]Invalid number. Enter 1–{len(tasks)}.[/red]"
                    )
                continue

            console.print("[red]Unrecognised input.[/red]")

        return tasks

    def _bulk_action(self, tasks: list[DownloadTask]) -> list[DownloadTask]:
        """
        Apply a single action to every task in the pre-download review.

        Available actions:
          skip all       — mark every non-local task as skipped
          download all   — clear skipped flag on every task that has a source
          skip local     — skip only tasks already on disk
          skip unresolved— skip only tasks with no source

        Args:
            tasks: Current task list.

        Returns:
            Updated task list.
        """
        from pathlib import Path

        console.print("\n[bold cyan]Bulk Action[/bold cyan]")
        console.print("  [bold]1[/bold]  Skip all")
        console.print("  [bold]2[/bold]  Download all  [dim](clear any skips)[/dim]")
        console.print("  [bold]3[/bold]  Skip local / already-present")
        console.print("  [bold]4[/bold]  Skip unresolved only")
        console.print("  [bold]5[/bold]  Set destination for all  [dim](override folder)[/dim]")
        console.print("  [bold]6[/bold]  Cancel")

        action = Prompt.ask(
            "Action", choices=["1", "2", "3", "4", "5", "6"], default="6"
        )

        if action == "6":
            return tasks

        updated: list[DownloadTask] = []

        if action == "1":
            for t in tasks:
                src_type = t.result.source.source_type if t.result.source else None
                if src_type == "local":
                    updated.append(t)
                else:
                    skipped = ResolutionResult(
                        input_name=t.result.input_name,
                        resolved_name=t.result.resolved_name,
                        source=t.result.source,  # preserved so un-skip can restore
                        confidence="skipped",
                        error="Skipped by user (bulk)",
                        asset_type=t.result.asset_type,
                    )
                    updated.append(DownloadTask(
                        result=skipped,
                        destination=t.destination,
                        asset_type=t.asset_type,
                    ))
            console.print(
                f"[yellow]Marked {sum(1 for t in updated if t.result.confidence == 'skipped')} "
                f"asset(s) as skipped.[/yellow]"
            )

        elif action == "2":
            # Restore original confidence where we had a source but user skipped
            # We can't recover the original source after a skip, so just un-skip
            # tasks that still have a source set
            for t in tasks:
                if t.result.confidence in ("skipped", "deleted") and t.result.source:
                    restored = ResolutionResult(
                        input_name=t.result.input_name,
                        resolved_name=t.result.resolved_name,
                        source=t.result.source,
                        confidence="high",
                        error=None,
                        asset_type=t.result.asset_type,
                    )
                    updated.append(DownloadTask(
                        result=restored,
                        destination=t.destination,
                        asset_type=t.asset_type,
                    ))
                else:
                    updated.append(t)
            console.print("[green]All skips cleared.[/green]")

        elif action == "3":
            for t in tasks:
                src_type = t.result.source.source_type if t.result.source else None
                if src_type == "local":
                    skipped = ResolutionResult(
                        input_name=t.result.input_name,
                        resolved_name=t.result.resolved_name,
                        source=t.result.source,  # preserved so un-skip can restore
                        confidence="skipped",
                        error="Skipped by user (local, bulk)",
                        asset_type=t.result.asset_type,
                    )
                    updated.append(DownloadTask(
                        result=skipped,
                        destination=t.destination,
                        asset_type=t.asset_type,
                    ))
                else:
                    updated.append(t)
            skipped_count = sum(1 for t in updated if t.result.confidence == "skipped")
            console.print(f"[yellow]Skipped {skipped_count} local asset(s).[/yellow]")

        elif action == "4":
            for t in tasks:
                if not t.result.source or t.result.confidence == "unknown":
                    skipped = ResolutionResult(
                        input_name=t.result.input_name,
                        resolved_name=t.result.resolved_name,
                        source=t.result.source,  # preserved so un-skip can restore
                        confidence="skipped",
                        error="Skipped by user (unresolved, bulk)",
                        asset_type=t.result.asset_type,
                    )
                    updated.append(DownloadTask(
                        result=skipped,
                        destination=t.destination,
                        asset_type=t.asset_type,
                    ))
                else:
                    updated.append(t)
            skipped_count = sum(1 for t in updated if t.result.confidence == "skipped")
            console.print(f"[yellow]Skipped {skipped_count} unresolved asset(s).[/yellow]")

        elif action == "5":
            console.print(
                "\n[dim]Enter a new destination folder for all assets.[/dim]"
            )
            raw = Prompt.ask("Destination", default="").strip()
            if not raw:
                console.print("[dim]Cancelled — no changes made.[/dim]")
                return tasks
            new_dest = Path(raw)
            updated = [
                DownloadTask(
                    result=t.result,
                    destination=new_dest,
                    asset_type=t.asset_type,
                )
                for t in tasks
            ]
            console.print(
                f"[green]✓ Destination set to {new_dest} for all {len(updated)} asset(s).[/green]"
            )

        return updated if updated else tasks

    def _edit_task(self, task: DownloadTask) -> DownloadTask:
        """
        Prompt to modify or skip a single download task in the pre-download
        review.

        Args:
            task: The task to edit.

        Returns:
            Updated :class:`DownloadTask`.
        """
        from pathlib import Path

        is_skipped = task.result.confidence in ("skipped", "deleted")

        console.print(f"\n[cyan]Editing: {task.result.input_name}[/cyan]")
        console.print(f"  [dim]Current destination: {task.destination}[/dim]")
        if is_skipped:
            console.print(f"  [dim]Status: [yellow]SKIPPED[/yellow][/dim]")
        console.print("  [bold]1[/bold]  Change source")
        console.print("  [bold]2[/bold]  Change destination folder")
        if is_skipped:
            console.print("  [bold]3[/bold]  [green]Un-skip[/green]  [dim](restore to download)[/dim]")
        else:
            console.print("  [bold]3[/bold]  Skip this asset")
        console.print("  [bold]4[/bold]  Keep as-is")

        action = Prompt.ask("Action", choices=["1", "2", "3", "4"], default="4")

        if action == "3":
            if is_skipped:
                # Restore — we can't recover the original source after a skip
                # so mark as unknown and let the user edit source if needed
                restored = ResolutionResult(
                    input_name=task.result.input_name,
                    resolved_name=task.result.resolved_name,
                    source=task.result.source,  # may be None, user can edit
                    confidence="unknown" if not task.result.source else "high",
                    error=None,
                    asset_type=task.result.asset_type,
                )
                console.print(
                    "[green]✓ Un-skipped.[/green]"
                    + (
                        " [yellow]No source set — use option 1 or 2 to set one.[/yellow]"
                        if not task.result.source
                        else ""
                    )
                )
                return DownloadTask(
                    result=restored,
                    destination=task.destination,
                    asset_type=task.asset_type,
                )
            else:
                skipped = ResolutionResult(
                    input_name=task.result.input_name,
                    resolved_name=task.result.resolved_name,
                    source=task.result.source,  # preserve so un-skip can restore it
                    confidence="skipped",
                    error="Skipped by user",
                    asset_type=task.result.asset_type,
                )
                return DownloadTask(
                    result=skipped,
                    destination=task.destination,
                    asset_type=task.asset_type,
                )

        if action == "1":
            updated_result = self._edit_source(task.result)
            return DownloadTask(
                result=updated_result,
                destination=task.destination,
                asset_type=task.asset_type,
            )

        if action == "2":
            console.print(
                f"\n[dim]Current: {task.destination}\n"
                "Enter a new folder path, or leave blank to cancel.[/dim]"
            )
            raw = Prompt.ask("Destination", default="").strip()
            if raw:
                new_dest = Path(raw)
                console.print(f"[green]✓ Destination set to: {new_dest}[/green]")
                return DownloadTask(
                    result=task.result,
                    destination=new_dest,
                    asset_type=task.asset_type,
                )
            else:
                console.print("[dim]Unchanged.[/dim]")

        return task

    # ── Display helpers (called from cli.py) ────────────────────────────────

    def display_asset_list(self, results: list[ResolutionResult]) -> None:
        """
        Display a plain (non-interactive) asset table.

        Used in ``--auto`` and ``--auto-all`` modes where no picker is shown.

        Args:
            results: List of resolution results to display.
        """
        table = Table(title="Detected Assets", box=box.ROUNDED)
        table.add_column("Asset",      style="cyan")
        table.add_column("Type",       style="magenta")
        table.add_column("Source",     style="blue")
        table.add_column("Confidence", style="white")

        for result in sorted(results, key=_conf_rank):
            table.add_row(
                result.input_name,
                result.asset_type,
                _source_str(result),
                _conf_badge(result.confidence),
            )

        console.print(table)

    def display_dry_run_table(self, tasks: list[DownloadTask]) -> None:
        """
        Display a dry-run table showing what would be downloaded.

        Args:
            tasks: List of download tasks.
        """
        console.print(
            "\n[bold cyan]DRY RUN — no files will be downloaded[/bold cyan]\n"
        )

        table = Table(box=box.ROUNDED)
        table.add_column("Asset",       style="cyan")
        table.add_column("Source",      style="blue")
        table.add_column("Size",        style="yellow", justify="right")
        table.add_column("Destination", style="green")

        total_size      = 0
        unresolved_count = 0

        for task in tasks:
            src = task.result.source
            if src:
                size_bytes = src.file_size_bytes
                size_str   = self._format_bytes(size_bytes) if size_bytes else "—"
                if size_bytes:
                    total_size += size_bytes

                if src.source_type == "huggingface":
                    source_str = f"HuggingFace: {src.repo}"
                elif src.source_type == "civitai":
                    source_str = f"Civitai: {src.repo}"
                elif src.source_type == "direct_url":
                    source_str = "Direct URL"
                elif src.source_type == "local":
                    source_str = "Local (skip)"
                else:
                    source_str = src.source_type

                table.add_row(
                    task.result.input_name,
                    source_str,
                    size_str,
                    str(task.destination),
                )
            else:
                unresolved_count += 1
                table.add_row(
                    task.result.input_name,
                    "[red]UNRESOLVED[/red]",
                    "—",
                    "—",
                )

        console.print(table)

        resolvable = len(tasks) - unresolved_count
        console.print(
            f"\n[bold]Total: {self._format_bytes(total_size)} "
            f"across {resolvable} resolvable asset(s)[/bold]"
        )
        if unresolved_count:
            console.print(f"[yellow]{unresolved_count} unresolved[/yellow]")

    def display_download_summary(self, results: list[DownloadResult]) -> None:
        """
        Display a post-download summary table.

        SHA-256 warnings (file kept despite hash mismatch) are shown in yellow
        rather than red so they are clearly distinguished from hard failures.

        Args:
            results: List of download results.
        """
        console.print("\n[bold cyan]Download Summary[/bold cyan]\n")

        table = Table(box=box.ROUNDED)
        table.add_column("Asset",   style="cyan")
        table.add_column("Status",  style="white")
        table.add_column("Details", style="yellow")

        success_count = 0
        skipped_count = 0
        failed_count  = 0
        warning_count = 0

        for result in results:
            name = result.task.result.input_name

            if result.sha256_warning:
                status  = "[yellow]⚠ WARNING[/yellow]"
                details = "SHA-256 mismatch — file kept, verify manually"
                warning_count += 1
                success_count += 1
            elif result.success and result.skipped:
                status  = "[green]✓ SKIPPED[/green]"
                details = "Already exists"
                skipped_count += 1
            elif result.success:
                status  = "[green]✓ SUCCESS[/green]"
                details = "Downloaded"
                success_count += 1
            else:
                status  = "[red]✗ FAILED[/red]"
                details = result.error or "Unknown error"
                failed_count += 1

            table.add_row(name, status, details)

        console.print(table)

        console.print("\n[bold]Summary:[/bold]")
        console.print(f"  [green]Downloaded : {success_count}[/green]")
        console.print(f"  [yellow]Skipped    : {skipped_count}[/yellow]")
        if warning_count:
            console.print(
                f"  [yellow]⚠ Hash warnings : {warning_count} "
                f"(files kept — verify manually)[/yellow]"
            )
        if failed_count:
            console.print(f"  [red]Failed     : {failed_count}[/red]")

    # ── Utility ─────────────────────────────────────────────────────────────

    @staticmethod
    def _format_bytes(size_bytes: int) -> str:
        """Return a human-readable byte size string, e.g. ``1.45 GB``."""
        size = float(size_bytes)
        for unit in ("B", "KB", "MB", "GB", "TB"):
            if size < 1024.0:
                return f"{size:.2f} {unit}"
            size /= 1024.0
        return f"{size:.2f} PB"