"""Item commands."""

from __future__ import annotations

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

import click
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 json_option, project_option
from ..output import OutputFormatter
from ..styles import PRIMARY_TEXT_STYLE, SECONDARY_TEXT_STYLE
from ._auth import authenticated_client, require_auth
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 allowed with --data flag (text-based content)
_DATA_MIME_ALIASES = {
    "plain": "text/plain",
    "text/plain": "text/plain",
    "markdown": "text/markdown",
    "text/markdown": "text/markdown",
    "json": "application/json",
    "application/json": "application/json",
}
_VALID_DATA_MIME_TYPES = {"text/plain", "text/markdown", "application/json"}

# 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",
}
_MAX_CLIPBOARD_BYTES = 10_000_000
_MAX_STDOUT_WARN_BYTES = 50_000_000
_TEXT_DOWNLOAD_MIME_TYPES = {
    "application/json",
    "application/xml",
    "application/javascript",
    "application/yaml",
}


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


def _validate_data_mime_type(mime_type: str | None) -> str:
    """Validate and normalize MIME type for --data flag.

    Args:
        mime_type: The MIME type to validate, or None for default.

    Returns:
        Normalized MIME type string.

    Raises:
        CLIError: If MIME type is invalid for --data.
    """
    if mime_type is None:
        return "text/plain"

    normalized = mime_type.lower().strip()
    if normalized in _DATA_MIME_ALIASES:
        return _DATA_MIME_ALIASES[normalized]

    raise CLIError(
        f"Invalid MIME type '{mime_type}' for --data. "
        "Allowed types: plain, markdown, json (or their full MIME types)."
    )


def _is_text_mime_type(mime_type: str | None) -> bool:
    normalized = _normalize_mime_type(mime_type)
    if not normalized:
        return False
    if normalized.startswith("text/"):
        return True
    return normalized in _TEXT_DOWNLOAD_MIME_TYPES


def _format_mb(size_bytes: int) -> str:
    return f"{size_bytes / 1_000_000:.1f}MB"


def _safe_filename(filename: str | None) -> str:
    if not filename:
        return "file"
    name = Path(filename).name
    return name or "file"


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 _derive_key_from_title(title: str) -> str:
    """Derive a valid item key from a title.

    Examples:
        "My Doc" -> "my-doc"
        "Hello World!" -> "hello-world"
        "  Multiple   Spaces  " -> "multiple-spaces"
    """
    # Lowercase and strip
    key = title.lower().strip()
    # Replace spaces and underscores with hyphens
    key = re.sub(r"[\s_]+", "-", key)
    # Remove invalid characters (keep alphanumeric and hyphens)
    key = re.sub(r"[^a-z0-9-]", "", key)
    # Collapse multiple hyphens
    key = re.sub(r"-+", "-", key)
    # Trim leading/trailing hyphens
    key = key.strip("-")
    return key


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 _resolve_file_dest_path(
    dest: Path,
    *,
    dest_has_trailing_sep: bool,
    filename: str,
) -> Path:
    safe_name = _safe_filename(filename)

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

    if dest.exists() and dest.is_dir():
        return dest / safe_name

    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 / safe_name


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, console: Console | None = None
) -> tuple[str, str]:
    """Resolve MIME type for --src file uploads.

    Per spec, MIME type is derived from file extension. --mime-type is ignored with a note.

    Returns:
        Tuple of (resolved_mime_type, routing_mime_type).
    """
    # MIME type is always derived from file extension when using --src
    derived = _mime_type_for_src(path)

    # If user provided --mime-type with --src, ignore it but show a note
    if override and console:
        console.print(
            f"[dim]Note: --mime-type ignored with --src; "
            f"using '{derived}' derived from file extension.[/dim]"
        )

    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 _compute_content_sha256(data: str) -> str:
    """Compute SHA-256 hash of content for deduplication."""
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def _compute_bytes_sha256(data: bytes) -> str:
    """Compute SHA-256 hash of binary content for deduplication."""
    return hashlib.sha256(data).hexdigest()


def _check_content_duplicate(
    client,
    project_key: str,
    content_sha256: str,
    content_type: str | None = None,
) -> str | None:
    """Check if content already exists in the project.

    Returns the existing item key if duplicate content found, None otherwise.
    """
    existing = client.get_item_by_content_sha256(
        project_key, content_sha256, content_type=content_type
    )
    if existing:
        return existing.key
    return None


def _find_next_sequential_suffix(client, project_key: str, base_key: str) -> str:
    """Find the next available sequential suffix for a key.

    Checks for existing items and finds the lowest available suffix:
    - If 'doc' exists, try 'doc-1'
    - If 'doc-1' exists, try 'doc-2'
    - And so on...

    Returns:
        The next available key (e.g., 'doc-1', 'doc-2').
    """
    suffix = 1
    while suffix < 1000:  # Reasonable upper limit
        candidate = f"{base_key}-{suffix}"
        if not _item_exists(client, project_key, candidate):
            return candidate
        suffix += 1
    # Fallback to timestamp if somehow we hit 1000 duplicates
    return f"{base_key}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"


def _resolve_conflict(
    *,
    cli_ctx: CLIContext,
    client,
    project_key: str,
    item_key: str,
    force: bool,
    rename: bool,
    content_sha256: str | None = None,
) -> str:
    """Resolve item key conflicts.

    Args:
        cli_ctx: CLI context.
        client: Backend client.
        project_key: Target project.
        item_key: Desired item key.
        force: If True, overwrite existing key without prompting.
        rename: If True, prompt for a new key with suggested suffix.
        content_sha256: Content hash for deduplication check.

    Returns:
        The final item key to use.

    Raises:
        CLIError: If content duplicate detected (always blocks).
        typer.Exit: If user cancels.
    """
    # Check for content duplicate first - this always blocks
    if content_sha256:
        existing_key = _check_content_duplicate(client, project_key, content_sha256)
        if existing_key:
            raise CLIError(
                f"Duplicate content: identical content already exists as '{existing_key}'. "
                "Content deduplication prevents creating duplicate items."
            )

    # Check for key conflict
    if not _item_exists(client, project_key, item_key):
        return item_key

    # Interactive rename - prompt with suggested sequential suffix
    if rename:
        suggested_key = _find_next_sequential_suffix(client, project_key, item_key)
        cli_ctx.console.print(f'Item "{item_key}" already exists in project.')
        cli_ctx.console.print(f"Suggested key: [bold]{suggested_key}[/bold]\n")

        if not _is_tty():
            # In non-TTY mode with --rename, use suggested key automatically
            validated = _validate_item_key(suggested_key)
            # suggested_key is already validated as not existing by _find_next_sequential_suffix
            return validated

        while True:
            new_key = Prompt.ask(
                "Enter new key (or press Enter to use suggested)",
                default=suggested_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

    if force:
        return item_key

    if not _is_tty():
        raise CLIError(
            f"Item '{item_key}' exists. Use --force to overwrite or --rename 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":
            suggested_key = _find_next_sequential_suffix(client, project_key, item_key)
            cli_ctx.console.print(f"Suggested key: [bold]{suggested_key}[/bold]")
            new_key = Prompt.ask(
                "Enter new key (or press Enter to use suggested)",
                default=suggested_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 _format_processing_status(status: str | None) -> str:
    """Format processing status for display with color coding."""
    if status is None or status == "ready":
        return "[green]ready[/green]"
    if status == "processing":
        return "[yellow]processing[/yellow]"
    if status == "failed":
        return "[red]failed[/red]"
    return status


def _display_processing_status_warning(cli_ctx, item) -> None:
    """Display warning if item is not in ready state."""
    status = item.processing_status
    if status == "processing":
        cli_ctx.console.print(
            "[yellow]Warning: Item is still processing. Content may be incomplete.[/yellow]"
        )
        cli_ctx.console.print(
            "[yellow]Tip: Run 'ctx items get' again later to check status.[/yellow]"
        )
    elif status == "failed":
        cli_ctx.console.print(
            "[red]Warning: Item processing failed. Content may be unavailable.[/red]"
        )
        if item.processing_error:
            cli_ctx.console.print(f"[red]Error: {item.processing_error}[/red]")


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:
    # Create items sub-app for `ctx items <subcommand>` structure
    items_app = typer.Typer(help="Manage context items.")
    app.add_typer(items_app, name="items")

    # Enum-like constants for sort fields and order
    SORT_FIELDS = ["updated_at", "created_at", "key", "title"]
    DATE_SORT_FIELDS = {"updated_at", "created_at"}
    ORDER_OPTIONS = ["asc", "desc"]

    def _sort_items(
        items: list,
        sort_field: str,
        order: str | None,
    ) -> list:
        """Sort items by the specified field with smart defaults.

        Args:
            items: List of ItemSummary objects to sort.
            sort_field: Field to sort by (updated_at, created_at, key, title).
            order: Sort order ('asc' or 'desc'), or None for smart default.

        Returns:
            Sorted list of items.
        """
        # Smart defaults: date fields default to desc, text fields default to asc
        if order is None:
            order = "desc" if sort_field in DATE_SORT_FIELDS else "asc"

        reverse = order == "desc"

        def sort_key(item):
            if sort_field == "updated_at":
                date_val = item.updated_at
                if date_val is None:
                    # Items without dates go to end; partition and sort separately
                    return (1, item.key.lower())
                return (0, date_val, item.key.lower())
            elif sort_field == "created_at":
                date_val = item.created_at
                if date_val is None:
                    return (1, item.key.lower())
                return (0, date_val, item.key.lower())
            elif sort_field == "key":
                return (item.key.lower(), item.key)
            else:  # title
                return (item.title.lower(), item.key.lower())

        # For date fields, we need special handling for None values
        # They should always come last, regardless of sort order
        if sort_field in DATE_SORT_FIELDS:
            # Partition items with dates vs without
            with_dates = []
            without_dates = []
            for item in items:
                date_val = getattr(item, sort_field)
                if date_val is None:
                    without_dates.append(item)
                else:
                    with_dates.append(item)

            # Sort items with dates by date value (+ key tiebreaker)
            with_dates.sort(
                key=lambda x: (getattr(x, sort_field), x.key.lower()),
                reverse=reverse,
            )
            # Sort items without dates by key (ascending always for tiebreaker)
            without_dates.sort(key=lambda x: x.key.lower())

            return with_dates + without_dates

        return sorted(items, key=sort_key, reverse=reverse)

    # Default limit for pagination
    DEFAULT_CLI_LIMIT = 50

    # Commands are registered on both items_app (ctx items list) and app (ctx list)
    @items_app.command("list", help="List items for the target project.")
    @items_app.command("ls", hidden=True)
    @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(),
        all_projects: bool = typer.Option(
            False,
            "--all",
            "-a",
            help="List items across all projects.",
        ),
        limit: int | None = typer.Option(
            None,
            "--limit",
            "-l",
            min=1,
            max=200,
            help="Maximum number of items to return (default 50, range 1-200).",
        ),
        cursor: str | None = typer.Option(
            None,
            "--cursor",
            help="Pagination cursor from previous response (requires --json).",
        ),
        no_limit: bool = typer.Option(
            False,
            "--no-limit",
            help="Return all items by fetching all pages (may be slow for large projects).",
        ),
        sort: str = typer.Option(
            "updated_at",
            "--sort",
            "-s",
            help="Sort by field. Date fields default to descending; text fields to ascending.",
            click_type=click.Choice(SORT_FIELDS, case_sensitive=False),
        ),
        order: str | None = typer.Option(
            None,
            "--order",
            "-o",
            help="Sort order. Overrides smart default (dates: desc, text: asc).",
            click_type=click.Choice(ORDER_OPTIONS, case_sensitive=False),
        ),
        json_output: bool = json_option(),
    ) -> None:
        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)

        # Validate --cursor requires --json
        if cursor is not None and not json_output:
            raise CLIError("--cursor requires --json flag for structured output.")

        # Validate --limit and --no-limit are mutually exclusive
        if limit is not None and no_limit:
            raise CLIError("Cannot use --limit and --no-limit together.")

        # Warn about --no-limit for large projects
        if no_limit and not json_output:
            cli_ctx.console.print(
                "[yellow]Warning: --no-limit fetches all items which may be slow "
                "for large projects.[/yellow]"
            )

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

        # Load config and check auth before any project operations
        config = _load_config_with_auth(cli_ctx)

        # Determine if we're doing an all-projects list
        if all_projects:
            # Explicit --all flag - list across all projects
            pass
        else:
            # Try to resolve project
            list_project = project or config.default_project
            if not list_project:
                # No project specified and no default -> error
                raise CLIError(
                    "No project specified and no default project set.\n"
                    "Use --project <key> to specify a project, "
                    "or --all to list across all projects."
                )

        # Determine effective limit
        effective_limit: int | None = None
        if no_limit:
            effective_limit = None  # Will fetch all pages
        elif limit is not None:
            effective_limit = limit
        else:
            effective_limit = DEFAULT_CLI_LIMIT

        if all_projects:
            # Check for zero projects edge case
            with authenticated_client(cli_ctx, config) as client:
                projects = client.list_projects()
                if not projects:
                    if json_output:
                        formatter.print_json({"items": [], "has_more": False})
                    else:
                        cli_ctx.console.print(
                            "[yellow]No projects found. "
                            "Create one with: ctx projects create <name>[/yellow]"
                        )
                    return

            items = _list_all_projects(cli_ctx, config, limit=None)
            if not items:
                if json_output:
                    formatter.print_json({"items": [], "has_more": False})
                else:
                    cli_ctx.console.print("[yellow]No items found across any projects.[/yellow]")
                return

            # Apply sorting before limit
            items = _sort_items(items, sort, order)
            has_more = False
            if effective_limit is not None and len(items) > effective_limit:
                has_more = True
                items = items[:effective_limit]

            table_title = "Items across all projects"
            is_cross_project = True
            next_cursor = None  # Cross-project doesn't support cursor pagination
        else:
            # Single-project list with server-side pagination
            project_key, config = _resolve_project_with_auth(cli_ctx, project)
            with authenticated_client(cli_ctx, config) as client:
                if no_limit:
                    # Fetch all pages when --no-limit is used
                    all_items: list = []
                    page_cursor: str | None = None
                    while True:
                        result = client.list_items(
                            project_key,
                            limit=200,  # Max page size for efficiency
                            cursor=page_cursor,
                        )
                        all_items.extend(result.items)
                        if not result.has_more:
                            break
                        page_cursor = result.next_cursor
                    items = all_items
                    has_more = False
                    next_cursor = None
                else:
                    result = client.list_items(
                        project_key,
                        limit=effective_limit,
                        cursor=cursor,
                    )
                    items = result.items
                    has_more = result.has_more
                    next_cursor = result.next_cursor

            if not items:
                if json_output:
                    formatter.print_json(
                        {
                            "items": [],
                            "project_key": project_key,
                            "has_more": False,
                        }
                    )
                else:
                    cli_ctx.console.print(
                        f"[yellow]No items found for project '{project_key}'.[/yellow]"
                    )
                return

            # Note: Server already sorted; client-side sort only for display customization
            # When using cursor, we preserve server order to maintain pagination consistency
            if cursor is None:
                items = _sort_items(items, sort, order)

            table_title = f"Items in {project_key}"
            is_cross_project = False

        if json_output:
            json_items = [
                {
                    "project_key": entry.project_key,
                    "item_key": entry.key,
                    "title": entry.title,
                    "processing_status": getattr(entry, "processing_status", None),
                    "tags": entry.tags or [],
                    "snippet": entry.snippet,
                    "updated_at": entry.updated_at.isoformat() if entry.updated_at else None,
                    "created_at": entry.created_at.isoformat() if entry.created_at else None,
                }
                for entry in items
            ]
            json_response: dict = {
                "items": json_items,
                "has_more": has_more,
            }
            if not is_cross_project:
                json_response["project_key"] = project_key
            if next_cursor:
                json_response["next_cursor"] = next_cursor
            formatter.print_json(json_response)
            return

        table = Table(
            title=table_title,
            box=box.SIMPLE_HEAVY,
            border_style=SECONDARY_TEXT_STYLE,
            header_style=SECONDARY_TEXT_STYLE,
        )
        if is_cross_project:
            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("Status", 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:
            status = _format_processing_status(getattr(entry, "processing_status", None))
            if is_cross_project:
                table.add_row(
                    entry.project_key,
                    entry.key,
                    entry.title,
                    status,
                    ", ".join(entry.tags) if entry.tags else "-",
                    entry.snippet or "",
                )
            else:
                table.add_row(
                    entry.key,
                    entry.title,
                    status,
                    ", ".join(entry.tags) if entry.tags else "-",
                    entry.snippet or "",
                )
        cli_ctx.console.print(table)

        # Show pagination hint in human-readable mode
        if has_more and not json_output:
            cli_ctx.console.print(
                f"[dim]Showing first {len(items)} items. "
                "Use --json to get pagination cursor for more results.[/dim]"
            )

    @items_app.command("get", help="Retrieve an item and save it to disk.")
    @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.",
        ),
        text: bool = typer.Option(
            False,
            "--text",
            "-t",
            help="Return extracted text for PDFs (when available).",
        ),
        json_output: bool = json_option(),
    ) -> 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.")
        if json_output and (stdout or clipboard or dest is not None):
            raise typer.BadParameter(
                "--json is mutually exclusive with --stdout, --clipboard, and --dest."
            )

        # --text defaults to stdout if no other output specified
        if text and not (stdout or clipboard or dest or json_output):
            stdout = True

        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)
        project_key, config = _resolve_project_with_auth(cli_ctx, project)
        with authenticated_client(cli_ctx, config) as client:
            item = client.get_item(project_key, item_key, include_text=text)

            # JSON output mode - return item metadata
            if json_output:
                content_type = (getattr(item, "content_type", None) or "inline").lower()
                json_data = {
                    "project_key": project_key,
                    "item_key": item.key,
                    "title": item.title,
                    "mime_type": item.mime_type,
                    "tags": item.tags or [],
                    "content_type": content_type,
                }
                # Include data for inline items, file metadata for file items
                if content_type == "file":
                    json_data["file_id"] = getattr(item, "id", None)
                    json_data["size"] = getattr(item, "size", None)
                    json_data["original_filename"] = getattr(item, "original_filename", None)
                else:
                    json_data["data"] = item.data
                formatter.print_json(json_data)
                return

            content_type = (getattr(item, "content_type", None) or "inline").lower()
            warn_console = Console(stderr=True)

            if text:
                if content_type != "file":
                    raise CLIError("Extracted text is only available for PDF file items.")
                normalized_mime = _normalize_mime_type(item.mime_type)
                if normalized_mime != "application/pdf":
                    raise CLIError("Extracted text is only available for PDF file items.")
                if item.processing_status and item.processing_status != "ready":
                    status = item.processing_status
                    if item.processing_status == "failed" and item.processing_error:
                        raise CLIError(f"PDF extraction failed: {item.processing_error}")
                    raise CLIError(
                        f"Extracted text not available yet (status: {status}). " "Try again later."
                    )
                if item.extracted_text is None:
                    raise CLIError(
                        "No extracted text available. This PDF was uploaded before text "
                        "extraction storage was implemented."
                    )

                content = item.extracted_text
                if clipboard:
                    _copy_to_clipboard(content)
                    cli_ctx.console.print(f"Copied to clipboard ({len(content)} characters)")
                    return
                if stdout:
                    typer.echo(content, 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)
                output_path = _resolve_dest_path(
                    dest_path,
                    dest_has_trailing_sep=dest_has_trailing_sep,
                    item_key=item.key,
                    mime_type="text/plain",
                    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(content, encoding="utf-8")
                cli_ctx.console.print(f"[green]Saved[/green] {output_path}")
                return

            # Display processing status warning if not ready
            _display_processing_status_warning(cli_ctx, item)

            if content_type == "file":
                if clipboard and item.size is not None and item.size > _MAX_CLIPBOARD_BYTES:
                    raise CLIError(f"File too large for clipboard ({_format_mb(item.size)} > 10MB)")

                if stdout and item.size is not None and item.size > _MAX_STDOUT_WARN_BYTES:
                    warn_console.print(f"[yellow]File is large ({_format_mb(item.size)})[/yellow]")

                if clipboard or stdout:
                    # Check MIME type for text compatibility
                    if not _is_text_mime_type(item.mime_type):
                        target = "clipboard" if clipboard else "stdout"
                        mime = item.mime_type or "unknown"
                        hint = "Use --dest to save the file."
                        if _normalize_mime_type(item.mime_type) == "application/pdf":
                            hint = "Use --text to return extracted text, or --dest to save it."
                        raise CLIError(f"Cannot output binary file ({mime}) to {target}. {hint}")
                    binary_data = client.download_file(project_key, item.id)
                    if item.size is None:
                        actual_size = len(binary_data)
                        if clipboard and actual_size > _MAX_CLIPBOARD_BYTES:
                            raise CLIError(
                                f"File too large for clipboard ({_format_mb(actual_size)} > 10MB)"
                            )
                        if stdout and actual_size > _MAX_STDOUT_WARN_BYTES:
                            warn_console.print(
                                f"[yellow]File is large ({_format_mb(actual_size)})[/yellow]"
                            )
                    text_data = binary_data.decode("utf-8", errors="replace")
                    if "\ufffd" in text_data:
                        warn_console.print(
                            "[yellow]File contains non-UTF-8 characters (decoded with replacement)"
                            "[/yellow]"
                        )
                    if clipboard:
                        _copy_to_clipboard(text_data)
                        cli_ctx.console.print(f"Copied to clipboard ({len(text_data)} characters)")
                    else:
                        typer.echo(text_data, nl=False)
                    return

                binary_data = client.download_file(project_key, item.id)
                filename = item.original_filename or item.key
                if dest is None:
                    output_path = Path.cwd() / _safe_filename(filename)
                else:
                    dest_raw = dest or "."
                    dest_path = Path(dest_raw).expanduser()
                    dest_has_trailing_sep = _dest_has_trailing_sep(dest_raw)
                    output_path = _resolve_file_dest_path(
                        dest_path,
                        dest_has_trailing_sep=dest_has_trailing_sep,
                        filename=filename,
                    )
                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_bytes(binary_data)
                size_label = _format_mb(item.size or len(binary_data))
                cli_ctx.console.print(f"[green]Downloaded[/green] {output_path} ({size_label})")
                return

            if content_type != "inline":
                raise CLIError(f"Unknown content type: {item.content_type}")

            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. Use --stdout or --dest instead."
                    )

                content = item.data or ""

                # Warn on structured types that may not paste correctly
                if normalized_mime in _CLIPBOARD_WARN_MIME_TYPES:
                    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)
            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}")

    @items_app.command("read", help="Retrieve item content for AI context consumption.")
    @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.",
        ),
        json_output: bool = json_option(),
    ) -> 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.")
        if json_output and (stdout or clipboard):
            raise typer.BadParameter("--json is mutually exclusive with --stdout and --clipboard.")

        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)
        project_key, config = _resolve_project_with_auth(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()
        if json_output:
            formatter.print_json(
                {
                    "path": str(absolute_path),
                    "chars": len(content),
                    "truncated": truncated,
                    "project_key": project_key,
                    "item_key": item_key,
                    "title": item.title,
                    "mime_type": item.mime_type,
                }
            )
        else:
            typer.echo(f"path: {absolute_path}")
            typer.echo(f"chars: {len(content)}")
            typer.echo(f"truncated: {str(truncated).lower()}")

    @items_app.command("delete", help="Delete an item.")
    @items_app.command("rm", hidden=True)
    @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."),
        json_output: bool = json_option(),
    ) -> None:
        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)
        project_key, config = _resolve_project_with_auth(cli_ctx, project)
        # In JSON mode, skip confirmation (assume scripted usage)
        if not json_output:
            _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)
        if json_output:
            formatter.print_json(
                {
                    "status": "deleted",
                    "project_key": project_key,
                    "item_key": item_key,
                }
            )
        else:
            cli_ctx.console.print(
                f"[yellow]Deleted[/yellow] [bold]{project_key}/{item_key}[/bold]."
            )

    @items_app.command("create", help="Create or update an item.")
    @items_app.command("put", hidden=True)
    @app.command("create", help="Create or update an item.")
    @app.command("put", hidden=True)
    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.",
        ),
        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: bool = typer.Option(
            False,
            "--rename",
            help="Save with a different key if the item already exists (interactive).",
        ),
        tags: list[str] = typer.Option(
            None,
            "--tag",
            "-t",
            help="Tags to associate (repeatable).",
        ),
        json_output: bool = json_option(),
    ) -> None:
        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)

        # Expand tilde and validate paths (argument validation before auth)
        src_path = _expand_path(src, must_exist=True, is_file=True)

        # Validate argument combinations before auth check
        if src_path and item_key and key_override:
            raise typer.BadParameter(
                "Use either positional item key or --key with --src, not both.",
                param_hint="'--key'",
            )
        if key_override and not src_path:
            raise typer.BadParameter("--key can only be used with --src.", param_hint="'--key'")
        if src_path and data:
            raise typer.BadParameter(
                "Use --src by itself; --data is not compatible.",
                param_hint="'--src'",
            )
        if rename and force:
            raise typer.BadParameter(
                "Cannot use --rename and --force together. "
                "Use --rename for interactive key selection or --force to overwrite.",
                param_hint="'--rename'",
            )

        # Auth and project resolution after argument validation
        project_key, config = _resolve_project_with_auth(cli_ctx, project)

        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_hint="'--src'")
            resolved_mime_type, routing_mime_type = _resolve_src_mime_type(
                src_path, mime_type, cli_ctx.console
            )
            if routing_mime_type == "application/pdf":
                if (title or tags) and not json_output:
                    cli_ctx.console.print("Note: --title and --tag are ignored for PDF uploads")
                # Only send key if explicitly provided via --key flag
                validated_key = _validate_item_key(key_override) if key_override else None
                upload_name = _pdf_filename(item_key, src_path)
                payload_bytes = src_path.read_bytes()
                with authenticated_client(cli_ctx, config) as client:
                    content_sha256 = _compute_bytes_sha256(payload_bytes)
                    existing_key = _check_content_duplicate(
                        client,
                        project_key,
                        content_sha256,
                        content_type="file",
                    )
                    if existing_key:
                        if json_output:
                            formatter.print_json(
                                {
                                    "status": "duplicate",
                                    "project_key": project_key,
                                    "item_key": existing_key,
                                }
                            )
                        else:
                            cli_ctx.console.print(
                                f"[yellow]Duplicate detected[/yellow]: {existing_key} "
                                "(skipping upload)."
                            )
                        return
                    uploaded = client.upload_file(
                        project_key,
                        name=upload_name,
                        data=payload_bytes,
                        mime_type=resolved_mime_type,
                        key=validated_key,
                    )
                if json_output:
                    result = {
                        "status": "uploaded",
                        "project_key": project_key,
                        "file_id": uploaded.id,
                        "filename": uploaded.filename,
                        "mime_type": uploaded.mime_type,
                        "size": uploaded.size,
                    }
                    if validated_key:
                        result["item_key"] = validated_key
                    formatter.print_json(result)
                else:
                    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:
                # Try to derive key from title if provided
                if title:
                    item_key = _derive_key_from_title(title)
                    if not item_key:
                        raise typer.BadParameter(
                            "Could not derive a valid key from title. "
                            "Please provide an explicit item key.",
                            param_hint="'ITEM_KEY'",
                        )
                else:
                    raise typer.BadParameter(
                        "Item key is required unless using --src or --title.",
                        param_hint="'ITEM_KEY'",
                    )
            if data:
                payload = data
            else:
                payload = typer.prompt("Enter item data")
            # Validate MIME type for --data (only plain/markdown/json allowed)
            mime_type = _validate_data_mime_type(mime_type)
            item_key = _validate_item_key(item_key)

        # Compute content hash for deduplication check
        content_sha256 = _compute_content_sha256(payload)

        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,
                content_sha256=content_sha256,
            )
            item = client.put_item(
                project_key,
                item_key,
                data=payload,
                title=title,
                tags=tags or [],
                mime_type=mime_type,
            )
        if json_output:
            formatter.print_json(
                {
                    "status": "saved",
                    "project_key": project_key,
                    "item_key": item.key,
                    "title": item.title,
                    "mime_type": item.mime_type,
                    "tags": item.tags or [],
                }
            )
        else:
            cli_ctx.console.print(f"[green]Saved[/green] [bold]{project_key}/{item.key}[/bold].")

    @items_app.command("upload", help="Upload a file as an item (alias for 'create --src').")
    @app.command("upload", help="Upload a file as an item (alias for 'create --src').")
    @app.command("up", hidden=True)
    def upload_item(
        ctx: typer.Context,
        file_path: str = typer.Argument(..., help="Path to the file to upload."),
        item_key: str | None = typer.Option(
            None,
            "--key",
            "-k",
            help="Override derived item key (defaults to filename stem).",
        ),
        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: bool = typer.Option(
            False,
            "--rename",
            help="Save with a different key if the item already exists (interactive).",
        ),
        tags: list[str] = typer.Option(
            None,
            "--tag",
            "-t",
            help="Tags to associate (repeatable).",
        ),
        json_output: bool = json_option(),
    ) -> None:
        """Upload a file as a context item.

        This is a convenience alias for 'ctx items put --src <file>'.
        The item key is derived from the filename unless --key is specified.
        """
        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)

        # Validate flag exclusivity before any other processing
        if rename and force:
            raise typer.BadParameter(
                "Cannot use --rename and --force together. "
                "Use --rename for interactive key selection or --force to overwrite.",
                param_hint="'--rename'",
            )

        # Expand and validate path
        src_path = _expand_path(file_path, must_exist=True, is_file=True)
        if src_path is None:
            raise CLIError("File path is required.")

        # Auth and project resolution
        project_key, config = _resolve_project_with_auth(cli_ctx, project)

        # Derive item key from filename if not specified
        resolved_key = item_key or src_path.stem
        if not resolved_key:
            raise CLIError("Derived item key is empty.")

        resolved_mime_type, routing_mime_type = _resolve_src_mime_type(
            src_path, mime_type, cli_ctx.console
        )

        if routing_mime_type == "application/pdf":
            if (title or tags) and not json_output:
                cli_ctx.console.print("Note: --title and --tag are ignored for PDF uploads")
            # Only send key if explicitly provided via --key flag
            validated_key = _validate_item_key(item_key) if item_key else None
            upload_name = _pdf_filename(resolved_key, src_path)
            payload_bytes = src_path.read_bytes()
            with authenticated_client(cli_ctx, config) as client:
                content_sha256 = _compute_bytes_sha256(payload_bytes)
                existing_key = _check_content_duplicate(
                    client,
                    project_key,
                    content_sha256,
                    content_type="file",
                )
                if existing_key:
                    if json_output:
                        formatter.print_json(
                            {
                                "status": "duplicate",
                                "project_key": project_key,
                                "item_key": existing_key,
                            }
                        )
                    else:
                        cli_ctx.console.print(
                            f"[yellow]Duplicate detected[/yellow]: {existing_key} "
                            "(skipping upload)."
                        )
                    return
                uploaded = client.upload_file(
                    project_key,
                    name=upload_name,
                    data=payload_bytes,
                    mime_type=resolved_mime_type,
                    key=validated_key,
                )
            if json_output:
                result = {
                    "status": "uploaded",
                    "project_key": project_key,
                    "file_id": uploaded.id,
                    "filename": uploaded.filename,
                    "mime_type": uploaded.mime_type,
                    "size": uploaded.size,
                }
                if validated_key:
                    result["item_key"] = validated_key
                formatter.print_json(result)
            else:
                cli_ctx.console.print(
                    "[green]Uploaded[/green] "
                    f"[bold]{project_key}/{uploaded.id}[/bold] ({uploaded.filename})."
                )
            return

        validated_key = _validate_item_key(resolved_key)
        payload = src_path.read_text(encoding="utf-8")

        # Compute content hash for deduplication check
        content_sha256 = _compute_content_sha256(payload)

        with authenticated_client(cli_ctx, config) as client:
            validated_key = _resolve_conflict(
                cli_ctx=cli_ctx,
                client=client,
                project_key=project_key,
                item_key=validated_key,
                force=force,
                rename=rename,
                content_sha256=content_sha256,
            )
            item = client.put_item(
                project_key,
                validated_key,
                data=payload,
                title=title,
                tags=tags or [],
                mime_type=resolved_mime_type,
            )
        if json_output:
            formatter.print_json(
                {
                    "status": "saved",
                    "project_key": project_key,
                    "item_key": item.key,
                    "title": item.title,
                    "mime_type": item.mime_type,
                    "tags": item.tags or [],
                }
            )
        else:
            cli_ctx.console.print(f"[green]Saved[/green] [bold]{project_key}/{item.key}[/bold].")

    @items_app.command("search", help="Search items by keyword.")
    @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).",
        ),
        include_pending: bool = typer.Option(
            False,
            "--include-pending",
            help="Include items with processing or failed status in results.",
        ),
        json_output: bool = json_option(),
    ) -> None:
        cli_ctx = _ctx(ctx)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)

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

        # Load config and check auth before any project operations
        config = _load_config_with_auth(cli_ctx)

        # Determine if we're doing an all-projects search (explicit or implicit)
        implicit_all = False
        if all_projects:
            # Explicit --all flag
            pass
        else:
            # Try to resolve project
            search_project = project or config.default_project
            if not search_project:
                # No project specified and no default -> implicit all-projects mode
                implicit_all = True
                all_projects = True

        if all_projects:
            # Check for zero projects edge case
            with authenticated_client(cli_ctx, config) as client:
                projects = client.list_projects()
                if not projects:
                    if json_output:
                        formatter.print_json({"results": [], "query": query})
                    else:
                        cli_ctx.console.print(
                            "[yellow]No projects found. "
                            "Create one with: ctx projects create <name>[/yellow]"
                        )
                    return

            # Show informational message only when implicitly defaulting
            if implicit_all and not json_output:
                cli_ctx.console.print(
                    "[dim]Searching across all projects (no default project set)[/dim]"
                )

            results = _search_all_projects(cli_ctx, config, query, tags, limit, include_pending)
            if not results:
                if json_output:
                    formatter.print_json({"results": [], "query": query})
                else:
                    cli_ctx.console.print("[yellow]No matches found across any projects.[/yellow]")
                return
            table_title = "Search results across all projects"
        else:
            # Single-project search
            search_project, config = _resolve_project_with_auth(cli_ctx, project)
            with authenticated_client(cli_ctx, config) as client:
                results = client.search_items_v2(
                    query=query,
                    project_key=search_project,
                    tags=tags,
                    include_pending=include_pending,
                )
            if not results:
                if json_output:
                    formatter.print_json({"results": [], "query": query, "project": search_project})
                else:
                    cli_ctx.console.print(
                        f"[yellow]No matches found in '{search_project}'.[/yellow]"
                    )
                return
            table_title = f"Search results in {search_project}"

        if json_output:
            json_results = [
                {
                    "project_key": entry.project_key,
                    "item_key": entry.item_key,
                    "title": entry.title
                    or getattr(entry, "original_filename", None)
                    or entry.item_key,
                    "score": entry.score,
                    "snippet": entry.snippet,
                }
                for entry in results
            ]
            formatter.print_json({"results": json_results, "query": query})
            return

        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:
            # Title fallback: title or original_filename or item_key (defensive coding)
            display_title = (
                entry.title or getattr(entry, "original_filename", None) or entry.item_key
            )
            table.add_row(
                entry.project_key,
                entry.item_key,
                display_title,
                f"{entry.score:.2f}",
                entry.snippet or "",
            )
        cli_ctx.console.print(table)

    @items_app.command("update", help="Update item metadata.")
    @app.command("update", help="Update item metadata.")
    def update_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.",
        ),
        json_output: bool = json_option(),
    ) -> 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)
        formatter = OutputFormatter(console=cli_ctx.console, json_mode=json_output)

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

        # Auth and project resolution after argument validation
        project_key, config = _resolve_project_with_auth(cli_ctx, project)

        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:
                # No update flags provided - show help message
                raise CLIError(
                    "No update flags provided. Use --title, --mime-type, --tag, "
                    "--add-tag, --remove-tag, or --clear-tags to update the item.\n"
                    "Run 'ctx items update --help' for more information."
                )

            # 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

            if json_output:
                formatter.print_json(
                    {
                        "status": "updated",
                        "project_key": project_key,
                        "item_key": updated.key,
                        "title": updated.title,
                        "mime_type": updated.mime_type,
                        "tags": updated.tags or [],
                    }
                )
            else:
                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]:
    """Load config and resolve project key.

    Note: For auth-sensitive commands, call require_auth() before this function
    to ensure auth errors surface before project configuration errors.
    """
    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 _load_config_with_auth(cli_ctx: CLIContext) -> CLIConfig:
    """Load config and check authentication.

    Call this before _resolve_project() to ensure auth errors surface first.
    """
    config = cli_ctx.load_config()
    require_auth(config)
    return config


def _resolve_project_with_auth(cli_ctx: CLIContext, override: str | None) -> tuple[str, CLIConfig]:
    """Load config, check auth, then resolve project key.

    Ensures auth errors surface before project configuration errors.
    """
    config = _load_config_with_auth(cli_ctx)
    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,
    include_pending: bool = False,
) -> 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.
        include_pending: If True, include items with processing/failed status.

    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_v2(
                    query=query,
                    project_key=project_key,
                    tags=tags,
                    include_pending=include_pending,
                )
            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]


def _list_all_projects(
    cli_ctx: CLIContext,
    config: CLIConfig,
    limit: int | None = None,
) -> list:
    """List items across all projects using concurrent fan-out.

    Args:
        cli_ctx: CLI context for client and console access.
        config: CLI configuration.
        limit: Deprecated, not used. Sorting and limiting is done by caller.

    Returns:
        List of ItemSummary objects from all projects (unsorted).
    """
    with authenticated_client(cli_ctx, config) as client:
        projects = client.list_projects()

        if not projects:
            return []

        all_items = []

        def list_project(project_key: str) -> list:
            """List items from a single project, returning items or empty list on NotFoundError."""
            try:
                result = client.list_items(project_key)
                return list(result.items)
            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(list_project, p.key): p.key for p in projects}
            for future in concurrent.futures.as_completed(future_to_project):
                # Errors will raise here and propagate up
                items = future.result()
                all_items.extend(items)

        # Sorting and limiting is now done by caller
        return all_items
