"""Item commands."""

from __future__ import annotations

import concurrent.futures
import os
import re
import sys
from pathlib import Path

import typer
from platformdirs import user_cache_dir
from pyperclip import PyperclipException
from pyperclip import copy as pyperclip_copy
from rich import box
from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table

try:
    from shared import NotFoundError, PreconditionFailedError
except ImportError:
    from cli._vendor.shared import NotFoundError, PreconditionFailedError

from ..config import CACHE_DIR_ENV, CLIConfig
from ..context import CLIContext
from ..exceptions import CLIError, ConfigurationError
from ..options import project_option
from ..styles import PRIMARY_TEXT_STYLE, SECONDARY_TEXT_STYLE
from ._auth import authenticated_client
from ._context import require_cli_context


def _ctx(ctx: typer.Context) -> CLIContext:
    return require_cli_context(ctx)


_MIME_TO_EXTENSION = {
    "application/pdf": ".pdf",
    "text/markdown": ".md",
    "text/plain": ".txt",
}
_EXTENSION_TO_MIME = {
    ".md": "text/markdown",
    ".markdown": "text/markdown",
    ".pdf": "application/pdf",
    ".txt": "text/plain",
}
_SUPPORTED_EXTENSIONS = ", ".join(sorted(_EXTENSION_TO_MIME.keys()))
_UNSAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
ITEM_KEY_REGEX = re.compile(r"^[a-z0-9]+(?:[-_][a-z0-9]+)*$")
_ITEM_KEY_SEPARATOR_RE = re.compile(r"[-_]{2,}")
_ITEM_KEY_WHITESPACE_RE = re.compile(r"\s+")
_TEXT_MIME_TYPES = {"text/plain", "text/markdown"}
_SUPPORTED_SRC_MIME_TYPES = _TEXT_MIME_TYPES | {"application/pdf"}

# MIME types for clipboard operations
_CLIPBOARD_WARN_MIME_TYPES = {"application/json", "application/xml"}
_CLIPBOARD_BINARY_MIME_TYPES = {
    "application/pdf",
    "application/octet-stream",
    "image/png",
    "image/jpeg",
    "image/gif",
    "image/webp",
}


def _normalize_mime_type(mime_type: str | None) -> str:
    if not mime_type:
        return ""
    return mime_type.split(";", 1)[0].strip().lower()


def _extension_for_mime(mime_type: str | None, warn_console: Console) -> str:
    normalized = _normalize_mime_type(mime_type)
    ext = _MIME_TO_EXTENSION.get(normalized)
    if ext:
        return ext
    warn_console.print(
        f"[yellow]Unknown mime_type '{mime_type or 'unknown'}'. Defaulting to .txt.[/yellow]"
    )
    return ".txt"


def _sanitize_item_key(item_key: str) -> str:
    sanitized = item_key.strip()
    sanitized = sanitized.replace("\\", "_").replace("/", "_")
    sanitized = sanitized.replace("..", "_")
    sanitized = _UNSAFE_FILENAME_RE.sub("_", sanitized)
    sanitized = sanitized.strip("._-")
    return sanitized or "item"


def _normalize_item_key(item_key: str) -> str:
    normalized = item_key.lower().strip()
    normalized = _ITEM_KEY_WHITESPACE_RE.sub("-", normalized)
    normalized = _ITEM_KEY_SEPARATOR_RE.sub("-", normalized)
    return normalized.strip("-_")


def _validate_item_key(item_key: str) -> str:
    normalized = _normalize_item_key(item_key)
    if not normalized:
        raise CLIError("Item key must contain at least one letter or number.")
    if len(normalized) > 128:
        raise CLIError("Item key must be 1-128 characters long.")
    if not ITEM_KEY_REGEX.match(normalized):
        raise CLIError(
            "Invalid item key. Use lowercase letters, numbers, hyphens, and underscores."
        )
    return normalized


def _dest_has_trailing_sep(dest_raw: str) -> bool:
    separators = {"/", "\\"}
    if os.sep:
        separators.add(os.sep)
    if os.altsep:
        separators.add(os.altsep)
    return dest_raw.endswith(tuple(separators))


def _expand_path(
    value: str | None,
    *,
    must_exist: bool = True,
    is_file: bool = True,
    must_be_readable: bool = True,
) -> Path | None:
    """Expand tilde in path and optionally validate existence and readability.

    Args:
        value: The path string to expand, or None.
        must_exist: If True, validate that the path exists.
        is_file: If True and must_exist, validate that path is a file (not directory).
        must_be_readable: If True and must_exist, validate that path is readable.

    Returns:
        Expanded Path object, or None if value is None.

    Raises:
        typer.BadParameter: If validation fails.
    """
    if value is None:
        return None
    expanded = Path(value).expanduser()
    if must_exist:
        if not expanded.exists():
            raise typer.BadParameter(f"Path does not exist: {expanded}")
        if is_file and not expanded.is_file():
            raise typer.BadParameter(f"Path is not a file: {expanded}")
        if must_be_readable and not os.access(expanded, os.R_OK):
            raise typer.BadParameter(f"Path is not readable: {expanded}")
    return expanded


def _resolve_dest_path(
    dest: Path,
    *,
    dest_has_trailing_sep: bool,
    item_key: str,
    mime_type: str | None,
    warn_console: Console,
) -> Path:
    extension = _extension_for_mime(mime_type, warn_console)
    safe_key = _sanitize_item_key(item_key)
    if safe_key.lower().endswith(extension.lower()):
        filename = safe_key
    else:
        filename = f"{safe_key}{extension}"

    if dest_has_trailing_sep:
        if dest.exists() and not dest.is_dir():
            raise CLIError(f"Destination '{dest}' is not a directory.")
        return dest / filename

    if dest.suffix:
        if dest.exists() and dest.is_dir():
            raise CLIError(f"Destination '{dest}' is a directory.")
        return dest

    if dest.exists() and dest.is_file():
        raise CLIError(
            f"Destination '{dest}' exists as a file. Use a path with extension to save as file, "
            "or remove existing file."
        )
    return dest / filename


def _mime_type_for_src(path: Path) -> str:
    extension = path.suffix.lower()
    mime_type = _EXTENSION_TO_MIME.get(extension)
    if not mime_type:
        raise CLIError(f"Unsupported file type '{extension}'. Supported: {_SUPPORTED_EXTENSIONS}.")
    return mime_type


def _resolve_src_mime_type(path: Path, override: str | None) -> tuple[str, str]:
    if override:
        normalized = _normalize_mime_type(override)
        if normalized not in _SUPPORTED_SRC_MIME_TYPES:
            raise CLIError(
                "Unsupported MIME type "
                f"'{override}'. Supported: {', '.join(sorted(_SUPPORTED_SRC_MIME_TYPES))}."
            )
        return normalized, normalized
    derived = _mime_type_for_src(path)
    return derived, derived


def _pdf_filename(item_key: str, src: Path) -> str:
    base = item_key or src.stem
    suffix = Path(base).suffix.lower()
    if suffix in _EXTENSION_TO_MIME:
        base = Path(base).stem
    return f"{base}.pdf"


def _item_exists(client, project_key: str, item_key: str) -> bool:
    try:
        client.get_item(project_key, item_key)
    except NotFoundError:
        return False
    return True


def _is_tty() -> bool:
    return sys.stdin.isatty()


def _copy_to_clipboard(content: str) -> None:
    """Copy content to system clipboard.

    Args:
        content: The text content to copy.

    Raises:
        CLIError: If clipboard is unavailable (e.g., missing xclip/xsel on Linux).
    """
    try:
        pyperclip_copy(content)
    except PyperclipException as e:
        raise CLIError("Clipboard unavailable. On Linux, install xclip or xsel.") from e


def _get_cache_dir() -> Path:
    """Return the cache directory, respecting CTXME_CACHE_DIR override."""
    env_dir = os.getenv(CACHE_DIR_ENV)
    if env_dir:
        return Path(env_dir).expanduser()
    return Path(user_cache_dir("ctxme"))


def _resolve_conflict(
    *,
    cli_ctx: CLIContext,
    client,
    project_key: str,
    item_key: str,
    force: bool,
    rename: str | None,
) -> str:
    if not _item_exists(client, project_key, item_key):
        return item_key

    if rename:
        normalized = _validate_item_key(rename)
        if _item_exists(client, project_key, normalized):
            raise CLIError(f"Item '{rename}' also exists. Choose a different name.")
        return normalized

    if force:
        return item_key

    if not _is_tty():
        raise CLIError(
            f"Item '{item_key}' exists. Use --force to overwrite or --rename <new-key> to save "
            "with different name."
        )

    cli_ctx.console.print(f'Item "{item_key}" already exists in project.\n')
    while True:
        choice = Prompt.ask(
            "[O] Overwrite existing item\n[R] Rename\n[C] Cancel\n\nChoice",
            choices=["o", "r", "c"],
            default="c",
            case_sensitive=False,
        )
        if choice == "o":
            return item_key
        if choice == "r":
            new_key = Prompt.ask("Enter new key")
            try:
                normalized = _validate_item_key(new_key)
            except CLIError as exc:
                cli_ctx.console.print(f"[red]{exc}[/red]")
                continue
            if _item_exists(client, project_key, normalized):
                cli_ctx.console.print(
                    f"[red]Item '{normalized}' also exists. Choose a different name.[/red]"
                )
                continue
            return normalized
        raise typer.Exit(code=1)


def _confirm_delete(*, project_key: str, item_key: str, force: bool) -> None:
    if force:
        return
    if not _is_tty():
        raise CLIError("Use --force to delete items in non-interactive mode.")
    choice = Prompt.ask(
        f"Delete '{project_key}/{item_key}'?",
        choices=["y", "n"],
        default="n",
        case_sensitive=False,
    )
    if choice != "y":
        raise typer.Exit(code=1)


def register(app: typer.Typer) -> None:
    @app.command("list", help="List items for the target project.")
    @app.command("ls", hidden=True)
    def list_items(
        ctx: typer.Context,
        project: str = project_option(),
    ) -> None:
        cli_ctx = _ctx(ctx)
        project_key, config = _resolve_project(cli_ctx, project)
        with authenticated_client(cli_ctx, config) as client:
            items = client.list_items(project_key)
        if not items:
            cli_ctx.console.print(f"[yellow]No items found for project '{project_key}'.[/yellow]")
            return

        table = Table(
            title=f"Items in {project_key}",
            box=box.SIMPLE_HEAVY,
            border_style=SECONDARY_TEXT_STYLE,
            header_style=SECONDARY_TEXT_STYLE,
        )
        table.add_column("Key", style=PRIMARY_TEXT_STYLE)
        table.add_column("Title", style=PRIMARY_TEXT_STYLE)
        table.add_column("Tags", style=PRIMARY_TEXT_STYLE)
        table.add_column("Snippet", style=PRIMARY_TEXT_STYLE, overflow="fold")
        for entry in items:
            table.add_row(
                entry.key,
                entry.title,
                ", ".join(entry.tags) if entry.tags else "-",
                entry.snippet or "",
            )
        cli_ctx.console.print(table)

    @app.command("get", help="Retrieve an item and save it to disk.")
    def get_item(
        ctx: typer.Context,
        item_key: str = typer.Argument(..., help="Item key to fetch."),
        project: str = project_option(),
        dest: str | None = typer.Option(
            None,
            "--dest",
            help="Save output to a file or directory (supports ~ expansion).",
        ),
        stdout: bool = typer.Option(
            False,
            "--stdout",
            help="Print item data to stdout instead of saving to disk.",
        ),
        clipboard: bool = typer.Option(
            False,
            "--clipboard",
            "-c",
            help="Copy item data to clipboard instead of saving to disk.",
        ),
        force: bool = typer.Option(
            False,
            "--force",
            help="Overwrite existing destination files.",
        ),
    ) -> None:
        # Mutual exclusivity checks
        if stdout and dest is not None:
            raise typer.BadParameter("--stdout and --dest are mutually exclusive.")
        if clipboard and stdout:
            raise typer.BadParameter("--clipboard and --stdout are mutually exclusive.")
        if clipboard and dest is not None:
            raise typer.BadParameter("--clipboard and --dest are mutually exclusive.")

        cli_ctx = _ctx(ctx)
        project_key, config = _resolve_project(cli_ctx, project)
        with authenticated_client(cli_ctx, config) as client:
            item = client.get_item(project_key, item_key)

        if clipboard:
            # Check MIME type for clipboard compatibility
            normalized_mime = _normalize_mime_type(item.mime_type)

            # Error on binary types
            if normalized_mime in _CLIPBOARD_BINARY_MIME_TYPES or normalized_mime.startswith(
                "image/"
            ):
                raise CLIError("Cannot copy binary content to clipboard.")

            content = item.data or ""

            # Warn on structured types that may not paste correctly
            if normalized_mime in _CLIPBOARD_WARN_MIME_TYPES:
                warn_console = Console(stderr=True)
                warn_console.print(
                    f"[yellow]Warning: mime type {normalized_mime} "
                    "may not paste correctly.[/yellow]"
                )

            _copy_to_clipboard(content)
            cli_ctx.console.print(f"Copied to clipboard ({len(content)} characters)")
            return

        if stdout:
            typer.echo(item.data or "", nl=False)
            return

        dest_raw = dest or "."
        dest_path = Path(dest_raw).expanduser()
        dest_has_trailing_sep = dest is not None and _dest_has_trailing_sep(dest_raw)
        warn_console = Console(stderr=True)
        output_path = _resolve_dest_path(
            dest_path,
            dest_has_trailing_sep=dest_has_trailing_sep,
            item_key=item.key,
            mime_type=item.mime_type,
            warn_console=warn_console,
        )
        if output_path.exists() and not force:
            raise CLIError(f"File '{output_path}' exists. Use --force to overwrite.")
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(item.data or "", encoding="utf-8")
        cli_ctx.console.print(f"[green]Saved[/green] {output_path}")

    @app.command("read", help="Retrieve item content for AI context consumption.")
    def read_item(
        ctx: typer.Context,
        item_key: str = typer.Argument(..., help="Item key to fetch."),
        project: str = project_option(),
        max_chars: int = typer.Option(
            5000,
            "--max-chars",
            help="Maximum characters to return (0 for unlimited).",
        ),
        stdout: bool = typer.Option(
            False,
            "--stdout",
            help="Print content to stdout instead of caching.",
        ),
        clipboard: bool = typer.Option(
            False,
            "--clipboard",
            "-c",
            help="Copy content to clipboard instead of caching.",
        ),
    ) -> None:
        """Retrieve item content and cache it locally for AI agent use.

        By default, writes content to a cache file and outputs metadata.
        Use --stdout to print content directly without caching.
        Use --clipboard to copy content to clipboard without caching.
        """
        # Mutual exclusivity check
        if clipboard and stdout:
            raise typer.BadParameter("--clipboard and --stdout are mutually exclusive.")

        cli_ctx = _ctx(ctx)
        project_key, config = _resolve_project(cli_ctx, project)

        with authenticated_client(cli_ctx, config) as client:
            item = client.get_item(project_key, item_key)

        # Validate MIME type (normalize first to handle charset suffixes)
        normalized_mime = _normalize_mime_type(item.mime_type)
        if normalized_mime not in _TEXT_MIME_TYPES:
            raise CLIError(
                f"Unsupported MIME type '{item.mime_type}'. "
                f"Only text/plain and text/markdown are supported."
            )

        content = item.data or ""

        # Apply truncation
        truncated = False
        if max_chars > 0 and len(content) > max_chars:
            content = content[:max_chars]
            truncated = True

        if clipboard:
            # Copy to clipboard without caching (security: avoid sensitive content on disk)
            _copy_to_clipboard(content)
            cli_ctx.console.print(f"Copied to clipboard ({len(content)} characters)")
            return

        if stdout:
            # Print content only, no metadata
            typer.echo(content, nl=False)
            return

        # Write to cache file
        cache_dir = _get_cache_dir()
        safe_project = _sanitize_item_key(project_key)
        safe_item = _sanitize_item_key(item_key)
        extension = _MIME_TO_EXTENSION.get(normalized_mime, ".txt")

        cache_path = cache_dir / safe_project / f"{safe_item}{extension}"
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        cache_path.write_text(content, encoding="utf-8")

        # Set secure permissions (0600)
        try:
            cache_path.chmod(0o600)
        except OSError:
            pass  # Best-effort on platforms that support POSIX permissions

        # Output metadata
        absolute_path = cache_path.resolve()
        typer.echo(f"path: {absolute_path}")
        typer.echo(f"chars: {len(content)}")
        typer.echo(f"truncated: {str(truncated).lower()}")

    @app.command("remove", help="Delete an item.")
    @app.command("rm", hidden=True)
    def remove_item(
        ctx: typer.Context,
        item_key: str = typer.Argument(..., help="Item key to delete."),
        project: str = project_option(),
        force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
    ) -> None:
        cli_ctx = _ctx(ctx)
        project_key, config = _resolve_project(cli_ctx, project)
        _confirm_delete(project_key=project_key, item_key=item_key, force=force)
        with authenticated_client(cli_ctx, config) as client:
            client.delete_item(project_key, item_key)
        cli_ctx.console.print(f"[yellow]Deleted[/yellow] [bold]{project_key}/{item_key}[/bold].")

    @app.command("put", help="Create or update an item.")
    def put_item(
        ctx: typer.Context,
        item_key: str | None = typer.Argument(None, help="Item key to create or update."),
        data: str = typer.Option(
            None,
            "--data",
            "-d",
            help="Raw text to store.",
        ),
        file: str | None = typer.Option(
            None,
            "--file",
            "-f",
            help="Read item data from a file (supports ~ expansion).",
        ),
        src: str | None = typer.Option(
            None,
            "--src",
            help="Upload from a source file (derives item key, supports ~ expansion).",
        ),
        key_override: str | None = typer.Option(
            None,
            "--key",
            help="Override derived item key (use with --src).",
        ),
        project: str = project_option(),
        title: str = typer.Option(None, "--title", help="Optional human-readable title."),
        mime_type: str | None = typer.Option(None, "--mime-type", help="Stored MIME type."),
        force: bool = typer.Option(
            False,
            "--force",
            help="Overwrite existing items without prompting.",
        ),
        rename: str | None = typer.Option(
            None,
            "--rename",
            help="Save with a different key if the item already exists.",
        ),
        tags: list[str] = typer.Option(
            None,
            "--tag",
            "-t",
            help="Tags to associate (repeatable).",
        ),
    ) -> None:
        cli_ctx = _ctx(ctx)
        project_key, config = _resolve_project(cli_ctx, project)

        # Expand tilde and validate paths
        src_path = _expand_path(src, must_exist=True, is_file=True)
        file_path = _expand_path(file, must_exist=True, is_file=True)

        if src_path and item_key and key_override:
            raise typer.BadParameter(
                "Use either positional item key or --key with --src, not both.",
                param_name="key",
            )
        if key_override and not src_path:
            raise typer.BadParameter("--key can only be used with --src.", param_name="key")
        if src_path and (data or file_path):
            raise typer.BadParameter(
                "Use --src by itself; --data/--file are not compatible.",
                param_name="src",
            )

        if src_path:
            item_key = key_override or item_key or src_path.stem
            if not item_key:
                raise typer.BadParameter("Derived item key is empty.", param_name="src")
            resolved_mime_type, routing_mime_type = _resolve_src_mime_type(src_path, mime_type)
            if routing_mime_type == "application/pdf":
                if title or tags:
                    cli_ctx.console.print("Note: --title and --tag are ignored for PDF uploads")
                upload_name = _pdf_filename(item_key, src_path)
                payload_bytes = src_path.read_bytes()
                with authenticated_client(cli_ctx, config) as client:
                    uploaded = client.upload_file(
                        project_key,
                        name=upload_name,
                        data=payload_bytes,
                        mime_type=resolved_mime_type,
                    )
                cli_ctx.console.print(
                    "[green]Uploaded[/green] "
                    f"[bold]{project_key}/{uploaded.id}[/bold] ({uploaded.filename})."
                )
                return
            item_key = _validate_item_key(item_key)
            mime_type = resolved_mime_type
            payload = src_path.read_text(encoding="utf-8")
        else:
            if item_key is None:
                raise typer.BadParameter(
                    "Item key is required unless using --src.", param_name="item_key"
                )
            if file_path and data:
                raise typer.BadParameter(
                    "Use either --data or --file, not both.", param_name="file"
                )
            if file_path:
                payload = file_path.read_text()
            elif data:
                payload = data
            else:
                payload = typer.prompt("Enter item data")
            mime_type = mime_type or "text/plain"
            item_key = _validate_item_key(item_key)

        with authenticated_client(cli_ctx, config) as client:
            item_key = _resolve_conflict(
                cli_ctx=cli_ctx,
                client=client,
                project_key=project_key,
                item_key=item_key,
                force=force,
                rename=rename,
            )
            item = client.put_item(
                project_key,
                item_key,
                data=payload,
                title=title,
                tags=tags or [],
                mime_type=mime_type,
            )
        cli_ctx.console.print(f"[green]Saved[/green] [bold]{project_key}/{item.key}[/bold].")

    @app.command("search", help="Search items by keyword.")
    def search_items(
        ctx: typer.Context,
        query: str = typer.Argument(..., help="Search query."),
        project: str = project_option(),
        all_projects: bool = typer.Option(
            False,
            "--all",
            "-a",
            help="Search across all projects.",
        ),
        limit: int = typer.Option(
            50,
            "--limit",
            "-l",
            help="Maximum number of results to return (only applies with --all).",
        ),
        tags: list[str] = typer.Option(
            [],
            "--tag",
            "-t",
            help="Filter by tag (AND semantics when multiple are provided).",
        ),
    ) -> None:
        cli_ctx = _ctx(ctx)

        # Mutual exclusivity check
        if all_projects and project:
            raise CLIError("Cannot use --all with --project. Use one or the other.")

        config = cli_ctx.load_config()

        if all_projects:
            results = _search_all_projects(cli_ctx, config, query, tags, limit)
            if not results:
                cli_ctx.console.print("[yellow]No matches found across any projects.[/yellow]")
                return
            table_title = "Search results across all projects"
        else:
            search_project, config = _resolve_project(cli_ctx, project)
            with authenticated_client(cli_ctx, config) as client:
                results = client.search_items(query=query, project_key=search_project, tags=tags)
            if not results:
                cli_ctx.console.print(f"[yellow]No matches found in '{search_project}'.[/yellow]")
                return
            table_title = f"Search results in {search_project}"

        table = Table(
            title=table_title,
            box=box.MINIMAL_DOUBLE_HEAD,
            border_style=SECONDARY_TEXT_STYLE,
            header_style=SECONDARY_TEXT_STYLE,
        )
        table.add_column("Project", style=PRIMARY_TEXT_STYLE)
        table.add_column("Key", style=PRIMARY_TEXT_STYLE)
        table.add_column("Title", style=PRIMARY_TEXT_STYLE)
        table.add_column("Score", justify="right", style=PRIMARY_TEXT_STYLE)
        table.add_column("Snippet", style=PRIMARY_TEXT_STYLE, overflow="fold")
        for entry in results:
            table.add_row(
                entry.project_key,
                entry.item_key,
                entry.title or entry.item_key,
                f"{entry.score:.2f}",
                entry.snippet or "",
            )
        cli_ctx.console.print(table)

    @app.command("set", help="View or update item metadata.")
    def set_item(
        ctx: typer.Context,
        item_key: str = typer.Argument(..., help="Item key to view or update."),
        project: str = project_option(),
        title: str | None = typer.Option(
            None,
            "--title",
            help="Set the item title.",
        ),
        mime_type: str | None = typer.Option(
            None,
            "--mime-type",
            help="Set the MIME type.",
        ),
        tags: list[str] | None = typer.Option(
            None,
            "--tag",
            "-t",
            help="Replace all tags (mutually exclusive with --add-tag/--remove-tag/--clear-tags).",
        ),
        add_tags: list[str] | None = typer.Option(
            None,
            "--add-tag",
            help="Add tags to existing tags (repeatable).",
        ),
        remove_tags: list[str] | None = typer.Option(
            None,
            "--remove-tag",
            help="Remove specific tags (repeatable).",
        ),
        clear_tags: bool = typer.Option(
            False,
            "--clear-tags",
            help="Remove all tags.",
        ),
    ) -> None:
        """View or update item metadata without modifying content.

        With no flags, displays current metadata.
        With flags, updates the specified metadata fields.
        """
        cli_ctx = _ctx(ctx)
        project_key, config = _resolve_project(cli_ctx, project)

        # Validate tag option exclusivity
        _validate_tag_exclusivity(
            tags=tags,
            add_tags=add_tags,
            remove_tags=remove_tags,
            clear_tags=clear_tags,
        )

        with authenticated_client(cli_ctx, config) as client:
            # Get current metadata and ETag
            metadata, etag = client.get_item_metadata(project_key, item_key)

            # Determine if we're updating or just viewing
            has_updates = (
                title is not None
                or mime_type is not None
                or tags is not None
                or add_tags is not None
                or remove_tags is not None
                or clear_tags
            )

            if not has_updates:
                # Display current metadata
                cli_ctx.console.print(f"[bold]Item:[/bold] {project_key}/{metadata.key}")
                cli_ctx.console.print(f"[bold]Title:[/bold] {metadata.title}")
                cli_ctx.console.print(f"[bold]MIME type:[/bold] {metadata.mime_type}")
                cli_ctx.console.print(
                    f"[bold]Tags:[/bold] {', '.join(metadata.tags) if metadata.tags else '(none)'}"
                )
                if metadata.content_sha256:
                    cli_ctx.console.print(
                        f"[bold]Content SHA-256:[/bold] {metadata.content_sha256}"
                    )
                if metadata.updated_at:
                    cli_ctx.console.print(f"[bold]Updated:[/bold] {metadata.updated_at}")
                return

            # Compute final tags
            final_tags = _compute_final_tags(
                current_tags=metadata.tags,
                tags=tags,
                add_tags=add_tags,
                remove_tags=remove_tags,
                clear_tags=clear_tags,
            )

            # Perform the update
            try:
                updated = client.patch_item(
                    project_key,
                    item_key,
                    etag,
                    title=title,
                    mime_type=mime_type,
                    tags=final_tags,
                )
            except PreconditionFailedError as exc:
                raise CLIError(
                    f"Item '{item_key}' was modified by another process. "
                    "Please retry the command."
                ) from exc

            cli_ctx.console.print(
                f"[green]Updated[/green] [bold]{project_key}/{updated.key}[/bold]"
            )


def _resolve_project(cli_ctx: CLIContext, override: str | None) -> tuple[str, CLIConfig]:
    config = cli_ctx.load_config()
    project_key = override or config.default_project
    if not project_key:
        raise ConfigurationError(
            "Project not specified. Pass --project or run 'ctxme set-project <project>'."
        )
    return project_key, config


def _validate_tag_exclusivity(
    *,
    tags: list[str] | None,
    add_tags: list[str] | None,
    remove_tags: list[str] | None,
    clear_tags: bool,
) -> None:
    """Validate that --tag is not used with --add-tag, --remove-tag, or --clear-tags."""
    if tags:
        if add_tags or remove_tags or clear_tags:
            raise CLIError(
                "--tag cannot be combined with --add-tag, --remove-tag, or --clear-tags. "
                "Use --tag to replace all tags, or use --add-tag/--remove-tag/--clear-tags "
                "to modify existing tags."
            )


def _normalize_tags(tags: list[str]) -> list[str]:
    """Normalize tags: strip whitespace, drop empties, dedupe, preserve order."""
    seen: set[str] = set()
    result: list[str] = []
    for tag in tags:
        normalized = tag.strip()
        if normalized and normalized not in seen:
            seen.add(normalized)
            result.append(normalized)
    return result


def _compute_final_tags(
    *,
    current_tags: list[str],
    tags: list[str] | None,
    add_tags: list[str] | None,
    remove_tags: list[str] | None,
    clear_tags: bool,
) -> list[str] | None:
    """Compute the final tags list based on the provided options.

    Returns None if no tag changes are requested.
    Tags are normalized: stripped, empty values removed, deduplicated, order preserved.
    """
    if tags:
        # Replace all tags - normalize input
        return _normalize_tags(tags)
    if clear_tags:
        # Start with empty list
        result: list[str] = []
        seen: set[str] = set()
    elif add_tags or remove_tags:
        # Start with current tags (preserve order, dedupe)
        result = _normalize_tags(current_tags)
        seen = set(result)
    else:
        return None

    if add_tags:
        # Normalize and add in provided order
        for tag in add_tags:
            normalized = tag.strip()
            if normalized and normalized not in seen:
                seen.add(normalized)
                result.append(normalized)
    if remove_tags:
        # Normalize and remove without reordering remaining tags
        remove_set = set(_normalize_tags(remove_tags))
        if remove_set:
            result = [tag for tag in result if tag not in remove_set]
    return result


def _search_all_projects(
    cli_ctx: CLIContext,
    config: CLIConfig,
    query: str,
    tags: list[str],
    limit: int,
) -> list:
    """Search across all projects using concurrent fan-out.

    Args:
        cli_ctx: CLI context for client and console access.
        config: CLI configuration.
        query: Search query string.
        tags: Tags to filter by.
        limit: Maximum number of results to return.

    Returns:
        List of SearchResult objects sorted by score desc, then updated_at desc.
    """
    with authenticated_client(cli_ctx, config) as client:
        projects = client.list_projects()

        if not projects:
            return []

        all_results = []

        def search_project(project_key: str) -> list:
            """Search a single project, returning results or empty list on NotFoundError."""
            try:
                return client.search_items(query=query, project_key=project_key, tags=tags)
            except NotFoundError:
                cli_ctx.console.print(
                    f"[yellow]Warning: Project '{project_key}' not found, skipping.[/yellow]"
                )
                return []

        with concurrent.futures.ThreadPoolExecutor() as executor:
            future_to_project = {executor.submit(search_project, p.key): p.key for p in projects}
            for future in concurrent.futures.as_completed(future_to_project):
                # Auth errors and server errors will raise here and propagate up
                results = future.result()
                all_results.extend(results)

        # Sort by score descending, then by updated_at descending (as tie-breaker)
        def sort_key(result):
            score = -result.score
            # Handle None updated_at by treating it as oldest (0 timestamp)
            updated_ts = -(result.updated_at.timestamp() if result.updated_at else 0)
            return (score, updated_ts)

        all_results.sort(key=sort_key)

        # Apply global limit
        return all_results[:limit]
