from __future__ import annotations

import importlib
import os
from datetime import datetime, timezone
from uuid import uuid4

from typer.testing import CliRunner

from cli.config import CACHE_DIR_ENV, CONFIG_DIR_ENV, CLIConfig, ConfigStore

try:
    from shared import (
        AuthenticationError,
        File,
        Item,
        ItemMetadata,
        NotFoundError,
        PreconditionFailedError,
    )
except ImportError:
    from cli._vendor.shared import (
        AuthenticationError,
        File,
        Item,
        ItemMetadata,
        NotFoundError,
        PreconditionFailedError,
    )


def _reload_cli_module():
    import cli.main as cli_main

    return importlib.reload(cli_main)


class StubFactory:
    def __init__(self, client) -> None:
        self._client = client

    def build(self, config: CLIConfig):
        return self._client


class RecordingClient:
    def __init__(
        self,
        *,
        existing_keys: set[str] | None = None,
        get_error: Exception | None = None,
        mime_type: str = "text/plain",
        initial_tags: list[str] | None = None,
        initial_title: str | None = None,
        force_etag_mismatch: bool = False,
        content_type: str = "inline",
        file_bytes: bytes | None = None,
        original_filename: str | None = None,
        size: int | None = None,
        include_size: bool = True,
        storage_key: str | None = None,
        item_id: str | None = None,
        extracted_text: str | None = None,
        processing_status: str | None = None,
        processing_error: str | None = None,
        duplicate_key: str | None = None,
        duplicate_content_type: str | None = None,
    ) -> None:
        self._existing_keys = existing_keys or set()
        self._get_error = get_error
        self._mime_type = mime_type
        self._initial_tags = initial_tags or []
        self._initial_title = initial_title
        self._force_etag_mismatch = force_etag_mismatch
        self._content_type = content_type
        self._file_bytes = file_bytes
        self._original_filename = original_filename
        self._size = size
        self._include_size = include_size
        self._storage_key = storage_key
        self._item_id = item_id or str(uuid4())
        self._extracted_text = extracted_text
        self._processing_status = processing_status
        self._processing_error = processing_error
        self._duplicate_key = duplicate_key
        self._duplicate_content_type = duplicate_content_type
        self._item_state: dict[str, dict] = {}
        self.content_sha256_calls: list[dict[str, str | None]] = []
        self.get_calls: list[dict[str, object]] = []
        self.put_calls: list[dict[str, object]] = []
        self.file_calls: list[dict[str, object]] = []
        self.delete_calls: list[dict[str, str]] = []
        self.patch_calls: list[dict[str, object]] = []
        self.download_calls: list[dict[str, str]] = []

    def _get_or_create_state(self, project_key: str, item_key: str) -> dict:
        key = f"{project_key}/{item_key}"
        if key not in self._item_state:
            self._item_state[key] = {
                "title": self._initial_title or item_key,
                "mime_type": self._mime_type,
                "tags": list(self._initial_tags),
                "etag": f'"{item_key}-v1"',
            }
        return self._item_state[key]

    def get_item(self, project_key: str, item_key: str, include_text: bool = False) -> Item:
        self.get_calls.append(
            {"project_key": project_key, "item_key": item_key, "include_text": include_text}
        )
        if self._get_error:
            raise self._get_error
        if item_key in self._existing_keys:
            state = self._get_or_create_state(project_key, item_key)
            file_bytes = self._file_bytes or b"payload"
            filename = self._original_filename or "document.txt"
            storage_key = self._storage_key or f"{project_key}/{self._item_id}"
            data = None if self._content_type == "file" else "payload"
            size_value = (
                (self._size if self._size is not None else len(file_bytes))
                if self._include_size
                else None
            )
            return Item(
                id=self._item_id,
                project_key=project_key,
                key=item_key,
                title=state["title"],
                data=data,
                extracted_text=self._extracted_text,
                mime_type=state["mime_type"],
                tags=state["tags"],
                content_type=self._content_type,
                processing_status=self._processing_status,
                processing_error=self._processing_error,
                original_filename=filename if self._content_type == "file" else None,
                size=size_value if self._content_type == "file" else None,
                storage_key=storage_key if self._content_type == "file" else None,
                created_at=datetime.now(tz=timezone.utc),
                updated_at=datetime.now(tz=timezone.utc),
            )
        raise NotFoundError("not found")

    def get_item_metadata(self, project_key: str, item_key: str) -> tuple[ItemMetadata, str]:
        if self._get_error:
            raise self._get_error
        if item_key in self._existing_keys:
            state = self._get_or_create_state(project_key, item_key)
            metadata = ItemMetadata(
                project_key=project_key,
                key=item_key,
                title=state["title"],
                mime_type=state["mime_type"],
                tags=state["tags"],
                content_sha256="abc123",
                etag=state["etag"],
                created_at=datetime.now(tz=timezone.utc),
                updated_at=datetime.now(tz=timezone.utc),
            )
            return metadata, state["etag"]
        raise NotFoundError("not found")

    def patch_item(
        self,
        project_key: str,
        item_key: str,
        etag: str,
        *,
        title: str | None = None,
        mime_type: str | None = None,
        tags: list[str] | None = None,
    ) -> ItemMetadata:
        if item_key not in self._existing_keys:
            raise NotFoundError("not found")
        state = self._get_or_create_state(project_key, item_key)

        # Simulate concurrent modification
        if self._force_etag_mismatch:
            raise PreconditionFailedError("ETag mismatch")

        # Check ETag
        if etag != state["etag"]:
            raise PreconditionFailedError("ETag mismatch")

        self.patch_calls.append(
            {
                "project_key": project_key,
                "item_key": item_key,
                "etag": etag,
                "title": title,
                "mime_type": mime_type,
                "tags": tags,
            }
        )

        # Apply updates
        if title is not None:
            state["title"] = title
        if mime_type is not None:
            state["mime_type"] = mime_type
        if tags is not None:
            state["tags"] = list(tags)

        # Update etag
        state["etag"] = f'"{item_key}-v2"'

        return ItemMetadata(
            project_key=project_key,
            key=item_key,
            title=state["title"],
            mime_type=state["mime_type"],
            tags=state["tags"],
            content_sha256="abc123",
            etag=state["etag"],
            created_at=datetime.now(tz=timezone.utc),
            updated_at=datetime.now(tz=timezone.utc),
        )

    def get_item_by_content_sha256(
        self, project_key: str, content_sha256: str, content_type: str | None = None
    ) -> Item | None:
        self.content_sha256_calls.append(
            {
                "project_key": project_key,
                "content_sha256": content_sha256,
                "content_type": content_type,
            }
        )
        if self._duplicate_key and (
            self._duplicate_content_type is None or self._duplicate_content_type == content_type
        ):
            return Item(
                id=self._item_id,
                project_key=project_key,
                key=self._duplicate_key,
                title=self._duplicate_key,
                data=None,
                extracted_text=None,
                mime_type="application/pdf",
                tags=[],
                content_type=content_type or "file",
                created_at=datetime.now(tz=timezone.utc),
                updated_at=datetime.now(tz=timezone.utc),
            )
        return None

    def put_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: list[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        self.put_calls.append(
            {
                "project_key": project_key,
                "item_key": item_key,
                "data": data,
                "title": title,
                "tags": tags or [],
                "mime_type": mime_type,
            }
        )
        return Item(
            id=str(uuid4()),
            project_key=project_key,
            key=item_key,
            title=title or item_key,
            data=data,
            mime_type=mime_type,
            tags=list(tags or []),
            content_type="inline",
            created_at=datetime.now(tz=timezone.utc),
            updated_at=datetime.now(tz=timezone.utc),
        )

    def delete_item(self, project_key: str, item_key: str) -> None:
        self.delete_calls.append({"project_key": project_key, "item_key": item_key})

    def upload_file(
        self,
        project_key: str,
        *,
        name: str,
        data: bytes,
        mime_type: str,
        key: str | None = None,
    ) -> File:
        file_id = str(uuid4())
        self.file_calls.append(
            {
                "project_key": project_key,
                "name": name,
                "data": data,
                "mime_type": mime_type,
                "key": key,
            }
        )
        return File(
            project_key=project_key,
            id=file_id,
            filename=name,
            size=len(data),
            mime_type=mime_type,
            checksum="checksum",
            storage_key=f"{project_key}/{file_id}",
            data=None,
        )

    def download_file(self, project_key: str, file_id: str) -> bytes:
        self.download_calls.append({"project_key": project_key, "file_id": file_id})
        return self._file_bytes or b"payload"

    def close(self) -> None:
        return


def test_get_default_downloads_to_cwd(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome"])

    assert result.exit_code == 0
    output_path = tmp_path / "welcome.txt"
    assert output_path.exists()
    assert "placeholder item" in output_path.read_text()


def test_get_stdout_prints_to_terminal(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--stdout"])

    assert result.exit_code == 0
    assert "placeholder item" in result.stdout
    assert not (tmp_path / "welcome.txt").exists()


def test_get_stdout_and_dest_are_mutually_exclusive(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["get", "welcome", "--stdout", "--dest", str(tmp_path / "out")],
    )

    assert result.exit_code != 0
    assert "mutually exclusive" in result.output


def test_get_dest_directory_saves_default_filename(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "out"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", f"{dest}{os.sep}"])

    assert result.exit_code == 0
    output_path = dest / "welcome.txt"
    assert output_path.exists()
    assert "placeholder item" in output_path.read_text()


def test_get_dest_without_extension_treated_as_directory(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "docs"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest)])

    assert result.exit_code == 0
    output_path = dest / "welcome.txt"
    assert output_path.exists()


def test_get_dest_without_extension_existing_file_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "docs"
    dest.write_text("existing")

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest)])

    assert result.exit_code != 0
    assert "exists as a file" in str(result.exception)


def test_get_dest_with_suffix_treated_as_filename(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "config.local"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest)])

    assert result.exit_code == 0
    assert dest.exists()
    assert dest.is_file()


def test_get_dest_with_suffix_existing_directory_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "output.md"
    dest.mkdir()

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest)])

    assert result.exit_code != 0
    assert "is a directory" in str(result.exception)


def test_get_dest_file_overwrite_requires_force(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "note.txt"
    dest.write_text("old")

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest)])

    assert result.exit_code != 0
    assert "File" in str(result.exception)

    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest), "--force"])
    assert result.exit_code == 0
    assert dest.read_text() != "old"


def test_get_dest_file_creates_parent_dirs(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "nested" / "docs" / "note.txt"

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", str(dest)])

    assert result.exit_code == 0
    assert dest.exists()
    assert "placeholder item" in dest.read_text()


def test_get_dest_unknown_mime_warns_to_stderr(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"welcome"}, mime_type="application/json")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "out"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", f"{dest}{os.sep}"])

    assert result.exit_code == 0
    assert "Unknown mime_type" in result.output
    assert (dest / "welcome.txt").exists()


def test_get_sanitizes_unsafe_key(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"../outside"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "downloads"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "../outside", "--dest", f"{dest}{os.sep}"])

    assert result.exit_code == 0
    assert not (tmp_path / "outside.txt").exists()
    assert (dest / "outside.txt").exists()


def test_get_dest_respects_item_key_extension(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"readme.md"}, mime_type="text/markdown")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "downloads"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "readme.md", "--dest", str(dest)])

    assert result.exit_code == 0
    assert (dest / "readme.md").exists()


def test_get_file_downloads_to_cwd(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="report.txt",
        file_bytes=b"file-bytes",
        mime_type="text/plain",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item"])

    assert result.exit_code == 0
    output_path = tmp_path / "report.txt"
    assert output_path.exists()
    assert output_path.read_bytes() == b"file-bytes"


def test_get_file_dest_downloads_to_directory(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="notes.txt",
        file_bytes=b"downloaded",
        mime_type="text/plain",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "downloads"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--dest", f"{dest}{os.sep}"])

    assert result.exit_code == 0
    output_path = dest / "notes.txt"
    assert output_path.exists()
    assert output_path.read_bytes() == b"downloaded"


def test_get_file_stdout_allows_text_mime_with_charset(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="utf8.txt",
        file_bytes=b"hello",
        mime_type="text/plain; charset=utf-8",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--stdout"])

    assert result.exit_code == 0
    assert result.stdout == "hello"


def test_get_file_clipboard_copies_text(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="notes.txt",
        file_bytes=b"clipboard",
        mime_type="text/plain",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--clipboard"])

    assert result.exit_code == 0
    assert copied_content == ["clipboard"]


def test_get_file_clipboard_binary_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="report.pdf",
        file_bytes=b"%PDF",
        mime_type="application/pdf",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--clipboard"])

    assert result.exit_code != 0
    assert "Cannot output binary file (application/pdf) to clipboard" in str(result.exception)


def test_get_file_stdout_binary_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="report.pdf",
        file_bytes=b"%PDF",
        mime_type="application/pdf",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--stdout"])

    assert result.exit_code != 0
    assert "Cannot output binary file (application/pdf) to stdout" in str(result.exception)


def test_get_file_text_outputs_extracted_text_stdout(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"pdf-item"},
        content_type="file",
        mime_type="application/pdf",
        extracted_text="Extracted PDF text",
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "pdf-item", "--text"])

    assert result.exit_code == 0
    assert result.stdout == "Extracted PDF text"
    assert client.download_calls == []
    assert client.get_calls[0]["include_text"] is True


def test_get_file_text_copies_to_clipboard(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"pdf-item"},
        content_type="file",
        mime_type="application/pdf",
        extracted_text="Clipboard PDF text",
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "pdf-item", "--text", "--clipboard"])

    assert result.exit_code == 0
    assert copied_content == ["Clipboard PDF text"]


def test_get_file_text_writes_to_dest(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"pdf-item"},
        content_type="file",
        mime_type="application/pdf",
        extracted_text="Saved PDF text",
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    dest = tmp_path / "out"
    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "pdf-item", "--text", "--dest", str(dest)])

    assert result.exit_code == 0
    output_path = dest / "pdf-item.txt"
    assert output_path.exists()
    assert output_path.read_text() == "Saved PDF text"


def test_get_file_text_non_pdf_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"text-item"},
        content_type="file",
        mime_type="text/plain",
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "text-item", "--text"])

    assert result.exit_code != 0
    assert "Extracted text is only available for PDF file items" in str(result.exception)


def test_get_file_text_processing_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"pdf-item"},
        content_type="file",
        mime_type="application/pdf",
        processing_status="processing",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "pdf-item", "--text"])

    assert result.exit_code != 0
    assert "Extracted text not available yet" in str(result.exception)


def test_get_file_text_legacy_pdf_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"pdf-item"},
        content_type="file",
        mime_type="application/pdf",
        extracted_text=None,
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "pdf-item", "--text"])

    assert result.exit_code != 0
    assert "No extracted text available" in str(result.exception)


def test_get_file_clipboard_size_limit_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="large.txt",
        file_bytes=b"tiny",
        size=10_000_001,
        mime_type="text/plain",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--clipboard"])

    assert result.exit_code != 0
    assert "File too large for clipboard" in str(result.exception)


def test_get_file_clipboard_size_limit_missing_metadata_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="large.txt",
        file_bytes=b"x" * 11,
        mime_type="text/plain",
        include_size=False,
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    monkeypatch.setattr("cli.commands.items._MAX_CLIPBOARD_BYTES", 10)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--clipboard"])

    assert result.exit_code != 0
    assert "File too large for clipboard" in str(result.exception)


def test_get_file_stdout_large_warns(tmp_path, monkeypatch):
    from rich.console import Console

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="large.txt",
        file_bytes=b"hello",
        size=50_000_001,
        mime_type="text/plain",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    warning_output = []
    original_console_init = Console.__init__

    def patched_console_init(self, *args, **kwargs):
        original_console_init(self, *args, **kwargs)
        if kwargs.get("stderr"):
            original_print = self.print

            def capture_print(*pargs, **pkwargs):
                warning_output.append(str(pargs[0]) if pargs else "")
                return original_print(*pargs, **pkwargs)

            self.print = capture_print

    monkeypatch.setattr(Console, "__init__", patched_console_init)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--stdout"])

    assert result.exit_code == 0
    assert any("File is large" in w for w in warning_output)


def test_get_file_stdout_invalid_utf8_warns(tmp_path, monkeypatch):
    from rich.console import Console

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"file-item"},
        content_type="file",
        original_filename="bad.txt",
        file_bytes=b"\xff\xfe\x00bad",
        mime_type="text/plain",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    warning_output = []
    original_console_init = Console.__init__

    def patched_console_init(self, *args, **kwargs):
        original_console_init(self, *args, **kwargs)
        if kwargs.get("stderr"):
            original_print = self.print

            def capture_print(*pargs, **pkwargs):
                warning_output.append(str(pargs[0]) if pargs else "")
                return original_print(*pargs, **pkwargs)

            self.print = capture_print

    monkeypatch.setattr(Console, "__init__", patched_console_init)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "file-item", "--stdout"])

    assert result.exit_code == 0
    assert any("non-UTF-8" in w for w in warning_output)


def test_put_src_derives_key_and_mime(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "README.md"
    src.write_text("hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", str(src)])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "readme"
    assert client.put_calls[0]["mime_type"] == "text/markdown"
    assert client.put_calls[0]["data"] == "hello"


def test_put_src_key_override(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", str(src), "--key", "custom-key"])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "custom-key"


def test_put_normalizes_item_key(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "My Item", "--data", "hello"])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "my-item"


def test_put_rejects_invalid_item_key(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "bad$key", "--data", "hello"])

    assert result.exit_code != 0
    assert "Invalid item key" in str(result.exception)


def test_put_src_allows_positional_key(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "note", "--src", str(src)])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "note"


def test_put_src_key_and_positional_key_are_mutually_exclusive(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello")

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "note", "--src", str(src), "--key", "alt"])

    assert result.exit_code != 0


def test_put_key_requires_src(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "note", "--key", "custom"])

    assert result.exit_code != 0


def test_put_src_rejects_unsupported_extension(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.csv"
    src.write_text("binary")

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", str(src)])

    assert result.exit_code != 0
    assert "Unsupported file type" in str(result.exception)


def test_put_src_pdf_uploads_file(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "doc.pdf"
    src.write_bytes(b"%PDF-1.4\n")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", str(src)])

    assert result.exit_code == 0
    assert client.put_calls == []
    assert client.file_calls[0]["name"] == "doc.pdf"
    assert client.file_calls[0]["mime_type"] == "application/pdf"


def test_put_src_pdf_dedup_skips_upload(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "doc.pdf"
    src.write_bytes(b"%PDF-1.4\n")

    client = RecordingClient(duplicate_key="existing-file", duplicate_content_type="file")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", str(src)])

    assert result.exit_code == 0
    assert client.file_calls == []
    assert client.content_sha256_calls
    assert client.content_sha256_calls[0]["content_type"] == "file"
    assert "Duplicate detected" in result.output


def test_put_src_mime_type_ignored_with_note(tmp_path, monkeypatch):
    """Verify --mime-type is ignored with --src and a note is shown."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["put", "--src", str(src), "--mime-type", "application/pdf"],
    )

    assert result.exit_code == 0
    # --mime-type is ignored; MIME derived from .txt extension → text/plain
    # Text files go through put_item, not file upload
    assert len(client.put_calls) == 1
    assert client.put_calls[0]["mime_type"] == "text/plain"
    assert client.file_calls == []
    # Note should be shown about ignoring --mime-type
    assert "--mime-type ignored" in result.stdout or "mime-type ignored" in result.stdout.lower()


def test_put_src_pdf_warns_on_metadata(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "doc.pdf"
    src.write_bytes(b"%PDF-1.4\n")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["put", "--src", str(src), "--title", "Doc", "--tag", "docs"],
    )

    assert result.exit_code == 0
    assert "Note: --title and --tag are ignored for PDF uploads" in result.output


def test_put_conflict_non_tty_requires_force_or_rename(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "welcome", "--data", "updated"])

    assert result.exit_code != 0
    assert "Use --force to overwrite or --rename" in str(result.exception)


def test_put_conflict_force_overwrites(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "welcome", "--data", "updated", "--force"])

    assert result.exit_code == 0


def test_put_conflict_rename_uses_sequential_suffix_in_non_tty(tmp_path, monkeypatch):
    """Test that --rename in non-TTY mode uses sequential suffix (e.g., welcome-1)."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["put", "welcome", "--data", "updated", "--rename"],
    )

    assert result.exit_code == 0
    # Key should have sequential suffix (e.g., welcome-1)
    assert "welcome-1" in result.stdout or "Saved" in result.stdout


def test_put_conflict_prompt_in_tty(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    import cli.commands.items as items

    monkeypatch.setattr(items, "_is_tty", lambda: True)

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["put", "welcome", "--data", "updated"],
        input="o\n",
    )

    assert result.exit_code == 0


def test_put_precheck_error_aborts(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(get_error=AuthenticationError("unauthorized"))
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "welcome", "--data", "updated"])

    assert result.exit_code != 0
    assert "Not signed in. Run 'ctxme auth login' to authenticate." in str(result.exception)


def test_remove_non_tty_requires_force(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["remove", "welcome"])

    assert result.exit_code != 0
    assert "Use --force to delete items in non-interactive mode" in str(result.exception)


def test_remove_force_deletes(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["remove", "welcome", "--force"])

    assert result.exit_code == 0
    assert "Deleted" in result.stdout


def test_rm_alias_deletes(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["rm", "welcome", "--force"])

    assert result.exit_code == 0


def test_remove_prompt_in_tty(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    import cli.commands.items as items

    monkeypatch.setattr(items, "_is_tty", lambda: True)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["remove", "welcome"], input="y\n")

    assert result.exit_code == 0
    assert client.delete_calls == [{"project_key": "core", "item_key": "welcome"}]


# =========================================================================
# read command tests
# =========================================================================


def test_read_writes_to_cache_and_outputs_metadata(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "my-item"])

    assert result.exit_code == 0
    assert "path:" in result.stdout
    assert "chars:" in result.stdout
    assert "truncated: false" in result.stdout

    cached_file = cache_dir / "core" / "my-item.txt"
    assert cached_file.exists()
    assert cached_file.read_text() == "payload"


def test_read_markdown_item_uses_md_extension(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"readme"}, mime_type="text/markdown")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="docs", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "readme"])

    assert result.exit_code == 0
    cached_file = cache_dir / "docs" / "readme.md"
    assert cached_file.exists()


def test_read_stdout_prints_content_only(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "my-item", "--stdout"])

    assert result.exit_code == 0
    assert result.stdout == "payload"
    assert "path:" not in result.stdout
    assert not (cache_dir / "core" / "my-item.txt").exists()


def test_read_max_chars_truncates_content(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    # Create client that returns long content
    client = RecordingClient(existing_keys={"long-item"}, mime_type="text/plain")
    # Monkey-patch the get_item to return longer content
    original_get = client.get_item

    def get_with_long_content(project_key: str, item_key: str, include_text: bool = False) -> Item:
        item = original_get(project_key, item_key, include_text=include_text)
        return Item(
            id=item.id,
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="x" * 10000,
            mime_type=item.mime_type,
            tags=item.tags,
            content_type=item.content_type,
            created_at=item.created_at,
            updated_at=item.updated_at,
        )

    client.get_item = get_with_long_content
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "long-item", "--max-chars", "100"])

    assert result.exit_code == 0
    assert "truncated: true" in result.stdout
    assert "chars: 100" in result.stdout

    cached_file = cache_dir / "core" / "long-item.txt"
    assert len(cached_file.read_text()) == 100


def test_read_max_chars_zero_disables_truncation(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"long-item"}, mime_type="text/plain")
    original_get = client.get_item

    def get_with_long_content(project_key: str, item_key: str, include_text: bool = False) -> Item:
        item = original_get(project_key, item_key, include_text=include_text)
        return Item(
            id=item.id,
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="x" * 10000,
            mime_type=item.mime_type,
            tags=item.tags,
            content_type=item.content_type,
            created_at=item.created_at,
            updated_at=item.updated_at,
        )

    client.get_item = get_with_long_content
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "long-item", "--max-chars", "0"])

    assert result.exit_code == 0
    assert "truncated: false" in result.stdout
    assert "chars: 10000" in result.stdout


def test_read_unsupported_mime_type_errors(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"binary"}, mime_type="application/pdf")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "binary"])

    assert result.exit_code != 0
    assert "Unsupported MIME type" in str(result.exception)


def test_read_normalizes_mime_type_with_charset(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"utf8-item"}, mime_type="text/plain; charset=utf-8")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "utf8-item"])

    assert result.exit_code == 0
    cached_file = cache_dir / "core" / "utf8-item.txt"
    assert cached_file.exists()


def test_read_sanitizes_project_and_item_keys(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"../escape"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="../bad-project", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "../escape"])

    assert result.exit_code == 0
    # Should not escape cache directory
    assert not (cache_dir.parent / "escape.txt").exists()
    # Should be sanitized
    assert (cache_dir / "bad-project" / "escape.txt").exists()


def test_read_empty_content(tmp_path, monkeypatch):
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"empty"}, mime_type="text/plain")
    original_get = client.get_item

    def get_empty(project_key: str, item_key: str, include_text: bool = False) -> Item:
        item = original_get(project_key, item_key, include_text=include_text)
        return Item(
            id=item.id,
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="",
            mime_type=item.mime_type,
            tags=item.tags,
            content_type=item.content_type,
            created_at=item.created_at,
            updated_at=item.updated_at,
        )

    client.get_item = get_empty
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "empty"])

    assert result.exit_code == 0
    assert "chars: 0" in result.stdout
    assert "truncated: false" in result.stdout


# =============================================================================
# Clipboard Tests for `get` command
# =============================================================================


def test_get_clipboard_copies_content(tmp_path, monkeypatch):
    """Test that --clipboard copies item content to clipboard."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code == 0
    assert "Copied to clipboard" in result.output
    assert "7 characters" in result.output  # "payload" is 7 chars
    assert copied_content == ["payload"]
    # Should NOT save to disk
    assert not (tmp_path / "myitem.txt").exists()


def test_get_clipboard_short_flag(tmp_path, monkeypatch):
    """Test that -c short flag works for clipboard."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "-c"])

    assert result.exit_code == 0
    assert copied_content == ["payload"]


def test_get_clipboard_and_stdout_mutually_exclusive(tmp_path, monkeypatch):
    """Test that --clipboard and --stdout cannot be used together."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--clipboard", "--stdout"])

    assert result.exit_code != 0
    assert "mutually exclusive" in result.output


def test_get_clipboard_and_dest_mutually_exclusive(tmp_path, monkeypatch):
    """Test that --clipboard and --dest cannot be used together."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["get", "welcome", "--clipboard", "--dest", str(tmp_path / "out")],
    )

    assert result.exit_code != 0
    assert "mutually exclusive" in result.output


def test_get_clipboard_text_mime_no_warning(tmp_path, monkeypatch):
    """Test that text/* MIME types work without warning."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/html")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code == 0
    assert "Warning" not in result.output
    assert copied_content == ["payload"]


def test_get_clipboard_json_warns(tmp_path, monkeypatch):
    """Test that application/json shows a warning."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="application/json")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []
    warning_output = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    # Capture warning output from the Console(stderr=True) instance
    from rich.console import Console

    original_console_init = Console.__init__

    def patched_console_init(self, *args, **kwargs):
        original_console_init(self, *args, **kwargs)
        if kwargs.get("stderr"):
            original_print = self.print

            def capture_print(*pargs, **pkwargs):
                warning_output.append(str(pargs[0]) if pargs else "")
                return original_print(*pargs, **pkwargs)

            self.print = capture_print

    monkeypatch.setattr(Console, "__init__", patched_console_init)
    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code == 0
    assert "Copied to clipboard" in result.output
    assert copied_content == ["payload"]
    # Verify warning was actually emitted
    assert any(
        "may not paste correctly" in w for w in warning_output
    ), f"Expected warning not found. Captured warnings: {warning_output}"


def test_get_clipboard_xml_warns(tmp_path, monkeypatch):
    """Test that application/xml shows a warning."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="application/xml")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []
    warning_output = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    # Capture warning output from the Console(stderr=True) instance
    from rich.console import Console

    original_console_init = Console.__init__

    def patched_console_init(self, *args, **kwargs):
        original_console_init(self, *args, **kwargs)
        if kwargs.get("stderr"):
            original_print = self.print

            def capture_print(*pargs, **pkwargs):
                warning_output.append(str(pargs[0]) if pargs else "")
                return original_print(*pargs, **pkwargs)

            self.print = capture_print

    monkeypatch.setattr(Console, "__init__", patched_console_init)
    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code == 0
    assert "Copied to clipboard" in result.output
    assert copied_content == ["payload"]
    # Verify warning was actually emitted
    assert any(
        "may not paste correctly" in w for w in warning_output
    ), f"Expected warning not found. Captured warnings: {warning_output}"


def test_get_clipboard_binary_pdf_errors(tmp_path, monkeypatch):
    """Test that application/pdf errors."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="application/pdf")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code != 0
    assert "Cannot copy binary content to clipboard" in str(result.exception)


def test_get_clipboard_binary_image_errors(tmp_path, monkeypatch):
    """Test that image/* MIME types error."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="image/png")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code != 0
    assert "Cannot copy binary content to clipboard" in str(result.exception)


def test_get_clipboard_octet_stream_errors(tmp_path, monkeypatch):
    """Test that application/octet-stream errors."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="application/octet-stream")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code != 0
    assert "Cannot copy binary content to clipboard" in str(result.exception)


def test_get_clipboard_unavailable_error(tmp_path, monkeypatch):
    """Test error message when clipboard is unavailable."""
    from pyperclip import PyperclipException

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    def mock_copy_fail(content: str) -> None:
        raise PyperclipException("No clipboard mechanism found")

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy_fail)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem", "--clipboard"])

    assert result.exit_code != 0
    assert "Clipboard unavailable" in str(result.exception)
    assert "xclip or xsel" in str(result.exception)


# =============================================================================
# Clipboard Tests for `read` command
# =============================================================================


def test_read_clipboard_copies_content(tmp_path, monkeypatch):
    """Test that --clipboard copies item content to clipboard."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "myitem", "--clipboard"])

    assert result.exit_code == 0
    assert "Copied to clipboard" in result.output
    assert "7 characters" in result.output  # "payload" is 7 chars
    assert copied_content == ["payload"]
    # Should NOT write to cache (security decision)
    assert not (cache_dir / "core" / "myitem.txt").exists()


def test_read_clipboard_short_flag(tmp_path, monkeypatch):
    """Test that -c short flag works for clipboard."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "myitem", "-c"])

    assert result.exit_code == 0
    assert copied_content == ["payload"]


def test_read_clipboard_and_stdout_mutually_exclusive(tmp_path, monkeypatch):
    """Test that --clipboard and --stdout cannot be used together."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "welcome", "--clipboard", "--stdout"])

    assert result.exit_code != 0
    assert "mutually exclusive" in result.output


def test_read_clipboard_respects_max_chars(tmp_path, monkeypatch):
    """Test that --clipboard respects --max-chars truncation."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    original_get = client.get_item

    def get_long_content(project_key: str, item_key: str, include_text: bool = False) -> Item:
        item = original_get(project_key, item_key, include_text=include_text)
        return Item(
            id=item.id,
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="x" * 100,  # 100 character content
            mime_type=item.mime_type,
            tags=item.tags,
            content_type=item.content_type,
            created_at=item.created_at,
            updated_at=item.updated_at,
        )

    client.get_item = get_long_content
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    copied_content = []

    def mock_copy(content: str) -> None:
        copied_content.append(content)

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "myitem", "--clipboard", "--max-chars", "10"])

    assert result.exit_code == 0
    assert "10 characters" in result.output
    assert len(copied_content[0]) == 10


def test_read_clipboard_skips_cache_write(tmp_path, monkeypatch):
    """Test that --clipboard does not write item content to cache directory (security)."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cache_dir = tmp_path / "cache"
    monkeypatch.setenv(CACHE_DIR_ENV, str(cache_dir))
    # Disable update check to avoid unrelated cache writes
    monkeypatch.setenv("CTXME_UPDATE_CHECK", "0")
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"secret-key"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    def mock_copy(content: str) -> None:
        pass

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "secret-key", "--clipboard"])

    assert result.exit_code == 0
    # Verify no item content was written to cache (security: don't persist secrets)
    # Note: update_check.json may exist from update checker, which is fine
    if cache_dir.exists():
        cached_files = list(cache_dir.rglob("*"))
        # Filter out update_check.json which is allowed
        item_cache_files = [
            f for f in cached_files if f.is_file() and f.name != "update_check.json"
        ]
        assert not item_cache_files, f"Unexpected files in cache: {item_cache_files}"


def test_read_clipboard_keeps_mime_restrictions(tmp_path, monkeypatch):
    """Test that read --clipboard still enforces text-only MIME types."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="application/pdf")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "myitem", "--clipboard"])

    assert result.exit_code != 0
    assert "Unsupported MIME type" in str(result.exception)
    assert "text/plain and text/markdown" in str(result.exception)


def test_read_clipboard_unavailable_error(tmp_path, monkeypatch):
    """Test error message when clipboard is unavailable."""
    from pyperclip import PyperclipException

    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"myitem"}, mime_type="text/plain")
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    def mock_copy_fail(content: str) -> None:
        raise PyperclipException("No clipboard mechanism found")

    monkeypatch.setattr("cli.commands.items.pyperclip_copy", mock_copy_fail)

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["read", "myitem", "--clipboard"])

    assert result.exit_code != 0
    assert "Clipboard unavailable" in str(result.exception)
    assert "xclip or xsel" in str(result.exception)


# =============================================================================
# Tilde Expansion Tests
# =============================================================================


def test_put_src_tilde_expansion(tmp_path, monkeypatch):
    """Test that --src expands ~ to home directory."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    # Set HOME to tmp_path so ~ expands to it
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    # Create a file in the fake home directory
    src_file = tmp_path / "notes.txt"
    src_file.write_text("hello from tilde")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", "~/notes.txt"])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "notes"
    assert client.put_calls[0]["data"] == "hello from tilde"


def test_get_dest_tilde_expansion(tmp_path, monkeypatch):
    """Test that --dest expands ~ to home directory."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "welcome", "--dest", "~/output/"])

    assert result.exit_code == 0
    # File should be in the expanded home directory
    output_path = tmp_path / "output" / "welcome.txt"
    assert output_path.exists()
    assert "placeholder item" in output_path.read_text()


def test_put_src_tilde_nonexistent_shows_expanded_path(tmp_path, monkeypatch):
    """Test that error messages show expanded path, not raw ~."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", "~/nonexistent.txt"])

    assert result.exit_code != 0
    # Error should show expanded path, not ~/nonexistent.txt
    # Rich may wrap the path across lines, so check for shorter substrings
    assert "nonexistent" in result.output
    assert "Path does not exist" in result.output
    assert "~/" not in result.output


def test_put_src_unreadable_file_shows_clear_error(tmp_path, monkeypatch):
    """Test that unreadable files produce a clear parameter error."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    # Create a file and make it unreadable
    src_file = tmp_path / "secret.txt"
    src_file.write_text("secret content")
    src_file.chmod(0o000)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    try:
        result = runner.invoke(cli_main.app, ["put", "--src", "~/secret.txt"])

        assert result.exit_code != 0
        assert "not readable" in result.output
    finally:
        # Restore permissions so tmp_path cleanup can delete the file
        src_file.chmod(0o644)


def test_put_src_unreadable_file_with_tilde_shows_clear_error(tmp_path, monkeypatch):
    """Test that unreadable files with --src using tilde produce a clear parameter error."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    # Create a file and make it unreadable
    data_file = tmp_path / "data.txt"
    data_file.write_text("some data")
    data_file.chmod(0o000)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    try:
        result = runner.invoke(cli_main.app, ["put", "--src", "~/data.txt"])

        assert result.exit_code != 0
        assert "not readable" in result.output
    finally:
        # Restore permissions so tmp_path cleanup can delete the file
        data_file.chmod(0o644)


# =============================================================================
# Update Command Tests (formerly "set")
# =============================================================================


def test_update_shows_help_without_flags(tmp_path, monkeypatch):
    """Test that update with no flags shows help message per issue #208 spec."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(
        existing_keys={"my-item"},
        initial_title="My Item Title",
        initial_tags=["tag1", "tag2"],
        mime_type="text/markdown",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item"])

    # Per issue #208 spec: "no flags -> shows help"
    assert result.exit_code == 1
    assert "No update flags provided" in str(result.exception)
    assert "--title" in str(result.exception)
    assert "--help" in str(result.exception)
    # Should not call patch
    assert client.patch_calls == []


def test_update_updates_title(tmp_path, monkeypatch):
    """Test that --title updates the item title."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--title", "New Title"])

    assert result.exit_code == 0
    assert "Updated" in result.output
    assert client.patch_calls[0]["title"] == "New Title"
    assert client.patch_calls[0]["mime_type"] is None
    assert client.patch_calls[0]["tags"] is None


def test_update_updates_mime_type(tmp_path, monkeypatch):
    """Test that --mime-type updates the MIME type."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--mime-type", "application/json"])

    assert result.exit_code == 0
    assert "Updated" in result.output
    assert client.patch_calls[0]["mime_type"] == "application/json"
    assert client.patch_calls[0]["title"] is None


def test_update_replaces_all_tags_with_tag_flag(tmp_path, monkeypatch):
    """Test that --tag replaces all tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["old1", "old2"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--tag", "new1", "--tag", "new2"])

    assert result.exit_code == 0
    assert client.patch_calls[0]["tags"] == ["new1", "new2"]


def test_update_add_tag_adds_to_existing(tmp_path, monkeypatch):
    """Test that --add-tag adds to existing tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["existing"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["update", "my-item", "--add-tag", "new-tag", "--add-tag", "second-tag"],
    )

    assert result.exit_code == 0
    assert client.patch_calls[0]["tags"] == ["existing", "new-tag", "second-tag"]


def test_update_remove_tag_removes_from_existing(tmp_path, monkeypatch):
    """Test that --remove-tag removes from existing tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["first", "remove", "second"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--remove-tag", "remove"])

    assert result.exit_code == 0
    assert client.patch_calls[0]["tags"] == ["first", "second"]


def test_update_clear_tags_removes_all(tmp_path, monkeypatch):
    """Test that --clear-tags removes all tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["tag1", "tag2"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--clear-tags"])

    assert result.exit_code == 0
    assert client.patch_calls[0]["tags"] == []


def test_update_clear_tags_then_add(tmp_path, monkeypatch):
    """Test that --clear-tags combined with --add-tag works correctly."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["old1", "old2"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["update", "my-item", "--clear-tags", "--add-tag", "fresh"]
    )

    assert result.exit_code == 0
    assert client.patch_calls[0]["tags"] == ["fresh"]


def test_update_tag_mutually_exclusive_with_add_tag(tmp_path, monkeypatch):
    """Test that --tag cannot be combined with --add-tag."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--tag", "a", "--add-tag", "b"])

    assert result.exit_code != 0
    assert "--tag cannot be combined" in str(result.exception)


def test_update_tag_mutually_exclusive_with_remove_tag(tmp_path, monkeypatch):
    """Test that --tag cannot be combined with --remove-tag."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["a"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--tag", "b", "--remove-tag", "a"])

    assert result.exit_code != 0
    assert "--tag cannot be combined" in str(result.exception)


def test_update_tag_mutually_exclusive_with_clear_tags(tmp_path, monkeypatch):
    """Test that --tag cannot be combined with --clear-tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--tag", "a", "--clear-tags"])

    assert result.exit_code != 0
    assert "--tag cannot be combined" in str(result.exception)


def test_update_multiple_updates_at_once(tmp_path, monkeypatch):
    """Test that multiple flags can be used together."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        [
            "update",
            "my-item",
            "--title",
            "New Title",
            "--mime-type",
            "text/markdown",
            "--add-tag",
            "new-tag",
        ],
    )

    assert result.exit_code == 0
    assert client.patch_calls[0]["title"] == "New Title"
    assert client.patch_calls[0]["mime_type"] == "text/markdown"
    assert "new-tag" in client.patch_calls[0]["tags"]


def test_update_item_not_found(tmp_path, monkeypatch):
    """Test error when item doesn't exist."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys=set())  # Empty - no items
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "nonexistent"])

    assert result.exit_code != 0
    assert isinstance(result.exception, NotFoundError)


def test_update_with_project_option(tmp_path, monkeypatch):
    """Test that --project option works."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="default-project", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["update", "my-item", "--project", "other-project", "--title", "New"]
    )

    assert result.exit_code == 0
    assert client.patch_calls[0]["project_key"] == "other-project"


def test_update_tags_normalized_strips_whitespace(tmp_path, monkeypatch):
    """Test that tags are normalized (whitespace stripped)."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["update", "my-item", "--tag", "  spaced  ", "--tag", "normal"]
    )

    assert result.exit_code == 0
    # Tags should be normalized (stripped) and preserve input order
    assert client.patch_calls[0]["tags"] == ["spaced", "normal"]


def test_update_tags_normalized_removes_empty(tmp_path, monkeypatch):
    """Test that empty tags are removed after normalization."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["update", "my-item", "--tag", "valid", "--tag", "   ", "--tag", ""]
    )

    assert result.exit_code == 0
    # Empty tags should be filtered out
    assert client.patch_calls[0]["tags"] == ["valid"]


def test_update_tags_normalized_dedupes(tmp_path, monkeypatch):
    """Test that duplicate tags are removed."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["update", "my-item", "--tag", "dupe", "--tag", "dupe", "--tag", "unique"],
    )

    assert result.exit_code == 0
    # Duplicates should be removed
    assert client.patch_calls[0]["tags"] == ["dupe", "unique"]


def test_update_add_tag_normalized(tmp_path, monkeypatch):
    """Test that --add-tag normalizes tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient(existing_keys={"my-item"}, initial_tags=["existing"])
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["update", "my-item", "--add-tag", "  spaced  ", "--add-tag", "   "]
    )

    assert result.exit_code == 0
    # Empty tags should be filtered, whitespace stripped
    assert client.patch_calls[0]["tags"] == ["existing", "spaced"]


def test_update_etag_mismatch_shows_clear_error(tmp_path, monkeypatch):
    """Test that ETag mismatch shows a user-friendly error."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    # Create client that will fail with ETag mismatch
    client = RecordingClient(existing_keys={"my-item"}, force_etag_mismatch=True)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["update", "my-item", "--title", "New Title"])

    assert result.exit_code != 0
    # Should show user-friendly error, not raw PreconditionFailedError
    assert "modified by another process" in str(result.exception)
    assert "Please retry" in str(result.exception)


# =============================================================================
# Upload Command Tests (alias for put --src)
# =============================================================================


def test_upload_basic(tmp_path, monkeypatch):
    """Test basic upload command functionality."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello from upload")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(src)])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "notes"
    assert client.put_calls[0]["data"] == "hello from upload"
    assert client.put_calls[0]["mime_type"] == "text/plain"


def test_upload_with_key_override(tmp_path, monkeypatch):
    """Test upload with --key to override derived key."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(src), "--key", "custom-key"])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "custom-key"


def test_upload_markdown_file(tmp_path, monkeypatch):
    """Test upload detects markdown MIME type."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "README.md"
    src.write_text("# Hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(src)])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "readme"
    assert client.put_calls[0]["mime_type"] == "text/markdown"


def test_upload_pdf_uses_file_upload(tmp_path, monkeypatch):
    """Test that PDF files use file upload endpoint."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "doc.pdf"
    src.write_bytes(b"%PDF-1.4\n")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(src)])

    assert result.exit_code == 0
    assert client.put_calls == []  # Not via put_item
    assert client.file_calls[0]["name"] == "doc.pdf"
    assert client.file_calls[0]["mime_type"] == "application/pdf"
    assert client.file_calls[0]["key"] is None  # No key specified


def test_upload_pdf_with_custom_key(tmp_path, monkeypatch):
    """Test that PDF files can be uploaded with a custom key via --key flag."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "document.pdf"
    src.write_bytes(b"%PDF-1.4\ntest content")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(src), "--key", "my-custom-key"])

    assert result.exit_code == 0
    assert client.put_calls == []  # Not via put_item
    assert client.file_calls[0]["name"] == "my-custom-key.pdf"
    assert client.file_calls[0]["mime_type"] == "application/pdf"
    assert client.file_calls[0]["key"] == "my-custom-key"


def test_upload_pdf_key_validation(tmp_path, monkeypatch):
    """Test that invalid keys are rejected for PDF uploads."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "document.pdf"
    src.write_bytes(b"%PDF-1.4\ntest")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    # Invalid key (only special chars)
    result = runner.invoke(cli_main.app, ["upload", str(src), "--key", "..."])

    assert result.exit_code != 0
    assert client.file_calls == []  # Should fail before making API call


def test_put_src_pdf_with_custom_key(tmp_path, monkeypatch):
    """Test that put --src with PDF respects --key flag."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "report.pdf"
    src.write_bytes(b"%PDF-1.4\nreport content")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "--src", str(src), "--key", "custom-report"])

    assert result.exit_code == 0
    assert client.put_calls == []  # Not via put_item
    assert client.file_calls[0]["key"] == "custom-report"
    assert client.file_calls[0]["mime_type"] == "application/pdf"


def test_upload_with_title_and_tags(tmp_path, monkeypatch):
    """Test upload with title and tags."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["upload", str(src), "--title", "My Notes", "--tag", "docs", "--tag", "important"],
    )

    assert result.exit_code == 0
    assert client.put_calls[0]["title"] == "My Notes"
    assert client.put_calls[0]["tags"] == ["docs", "important"]


def test_upload_force_overwrites(tmp_path, monkeypatch):
    """Test upload with --force to overwrite existing item."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("updated content")

    client = RecordingClient(existing_keys={"notes"})
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(src), "--force"])

    assert result.exit_code == 0
    assert client.put_calls[0]["data"] == "updated content"


def test_upload_tilde_expansion(tmp_path, monkeypatch):
    """Test upload expands ~ in file path."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "notes.txt"
    src.write_text("hello from tilde")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", "~/notes.txt"])

    assert result.exit_code == 0
    assert client.put_calls[0]["data"] == "hello from tilde"


def test_upload_nonexistent_file_errors(tmp_path, monkeypatch):
    """Test upload with nonexistent file shows error."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["upload", str(tmp_path / "nonexistent.txt")])

    assert result.exit_code != 0
    assert "Path does not exist" in result.output


# =============================================================================
# Processing Status Display Tests
# =============================================================================


class RecordingClientWithStatus(RecordingClient):
    """RecordingClient that supports processing_status field."""

    def __init__(self, *args, processing_status: str | None = None, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._processing_status = processing_status
        self._processing_error = None
        if processing_status == "failed":
            self._processing_error = "Test error message"

    def get_item(self, project_key: str, item_key: str, include_text: bool = False) -> Item:
        if self._get_error:
            raise self._get_error
        if item_key in self._existing_keys:
            state = self._get_or_create_state(project_key, item_key)
            return Item(
                id=str(uuid4()),
                project_key=project_key,
                key=item_key,
                title=state["title"],
                data="payload",
                extracted_text=None,
                mime_type=state["mime_type"],
                tags=state["tags"],
                content_type="inline",
                processing_status=self._processing_status,
                processing_error=self._processing_error,
                created_at=datetime.now(tz=timezone.utc),
                updated_at=datetime.now(tz=timezone.utc),
            )
        raise NotFoundError("not found")

    def list_items(
        self,
        project_key: str,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ):
        from shared import ItemListResult, ItemSummary

        summaries = []
        for item_key in self._existing_keys:
            state = self._get_or_create_state(project_key, item_key)
            summaries.append(
                ItemSummary(
                    project_key=project_key,
                    key=item_key,
                    title=state["title"],
                    snippet="snippet...",
                    tags=state["tags"],
                    processing_status=self._processing_status,
                )
            )
        return ItemListResult(items=summaries, has_more=False)


def test_get_shows_processing_warning(tmp_path, monkeypatch):
    """Test that get shows warning when item is processing."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    client = RecordingClientWithStatus(
        existing_keys={"myitem"},
        processing_status="processing",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem"])

    assert result.exit_code == 0
    assert "still processing" in result.output
    assert "may be incomplete" in result.output


def test_get_shows_failed_warning_with_error(tmp_path, monkeypatch):
    """Test that get shows error details when item processing failed."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    client = RecordingClientWithStatus(
        existing_keys={"myitem"},
        processing_status="failed",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem"])

    assert result.exit_code == 0
    assert "processing failed" in result.output
    assert "Test error message" in result.output


def test_get_no_warning_when_ready(tmp_path, monkeypatch):
    """Test that get shows no warning when item is ready."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.chdir(tmp_path)
    cli_main = _reload_cli_module()

    client = RecordingClientWithStatus(
        existing_keys={"myitem"},
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["get", "myitem"])

    assert result.exit_code == 0
    assert "processing" not in result.output.lower() or "processing_status" in result.output.lower()
    assert (tmp_path / "myitem.txt").exists()


def test_list_shows_processing_status(tmp_path, monkeypatch):
    """Test that list shows processing status column with correct status."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClientWithStatus(
        existing_keys={"myitem"},
        processing_status="processing",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls"])

    assert result.exit_code == 0
    assert "processing" in result.output


def test_list_shows_failed_status(tmp_path, monkeypatch):
    """Test that list shows failed status in red."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClientWithStatus(
        existing_keys={"myitem"},
        processing_status="failed",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls"])

    assert result.exit_code == 0
    assert "failed" in result.output


def test_list_shows_ready_status(tmp_path, monkeypatch):
    """Test that list shows ready status."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClientWithStatus(
        existing_keys={"myitem"},
        processing_status="ready",
    )
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls"])

    assert result.exit_code == 0
    assert "ready" in result.output


def test_search_include_pending_flag(tmp_path, monkeypatch):
    """Test that search passes include_pending flag to client."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    # Track search calls
    search_calls = []

    class SearchTrackingClient(RecordingClient):
        def search_items_v2(self, query, project_key, tags=None, include_pending=False):
            search_calls.append({"include_pending": include_pending})
            return []

    client = SearchTrackingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()

    # Without flag
    result = runner.invoke(cli_main.app, ["search", "test"])
    assert result.exit_code == 0
    assert search_calls[-1]["include_pending"] is False

    # With flag
    result = runner.invoke(cli_main.app, ["search", "test", "--include-pending"])
    assert result.exit_code == 0
    assert search_calls[-1]["include_pending"] is True


# =============================================================================
# Create Command Tests (primary command per #213)
# =============================================================================


def test_create_command_with_data(tmp_path, monkeypatch):
    """Test ctx items create --data works for inline content."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["create", "my-item", "--data", "hello world"])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "my-item"
    assert client.put_calls[0]["data"] == "hello world"


def test_create_command_with_src(tmp_path, monkeypatch):
    """Test ctx items create --src works for file content."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "content.txt"
    src.write_text("content from file")

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    # --src derives key from filename ("content")
    result = runner.invoke(cli_main.app, ["create", "--src", str(src)])

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "content"
    assert client.put_calls[0]["data"] == "content from file"


def test_create_validates_mutual_exclusion(tmp_path, monkeypatch):
    """Test that create validates --data and --src are mutually exclusive."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    src = tmp_path / "content.txt"
    src.write_text("content")

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["create", "my-item", "--data", "inline", "--src", str(src)]
    )

    # Should fail due to mutual exclusion
    assert result.exit_code != 0


def test_create_derives_key_from_title(tmp_path, monkeypatch):
    """Test that create derives item key from --title when no key provided."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["create", "--title", "My Document", "--data", "content here"]
    )

    assert result.exit_code == 0
    assert client.put_calls[0]["item_key"] == "my-document"
    assert client.put_calls[0]["title"] == "My Document"


def test_create_title_key_derivation_with_special_chars(tmp_path, monkeypatch):
    """Test that title-to-key handles special characters correctly."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    client = RecordingClient()
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["create", "--title", "Hello, World! #123", "--data", "test"]
    )

    assert result.exit_code == 0
    # Special chars removed, spaces become hyphens
    assert client.put_calls[0]["item_key"] == "hello-world-123"


# ========== Sort flag tests ==========


class SortingTestClient(RecordingClient):
    """Client that returns items with dates for sorting tests."""

    def __init__(self, items_data: list[dict]) -> None:
        super().__init__()
        self._items_data = items_data

    def list_items(self, project_key: str, *, limit: int | None = None, cursor: str | None = None):
        try:
            from shared import ItemListResult, ItemSummary
        except ImportError:
            from cli._vendor.shared import ItemListResult, ItemSummary

        summaries = []
        for item in self._items_data:
            summaries.append(
                ItemSummary(
                    project_key=project_key,
                    key=item["key"],
                    title=item.get("title", item["key"]),
                    snippet=item.get("snippet", "..."),
                    tags=item.get("tags", []),
                    processing_status=item.get("processing_status"),
                    updated_at=item.get("updated_at"),
                    created_at=item.get("created_at"),
                )
            )
        # Server-side sorting: updated_at DESC (with key as tiebreaker)
        summaries.sort(
            key=lambda x: (x.updated_at or datetime.min.replace(tzinfo=timezone.utc), x.key),
            reverse=True,
        )
        # Apply limit if specified (simulates server-side pagination)
        has_more = False
        if limit is not None and len(summaries) > limit:
            has_more = True
            summaries = summaries[:limit]
        return ItemListResult(items=summaries, has_more=has_more)


def test_list_sort_by_updated_at_desc_default(tmp_path, monkeypatch):
    """Test that list defaults to sorting by updated_at descending."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [
        {"key": "old", "updated_at": now.replace(year=2024)},
        {"key": "new", "updated_at": now.replace(year=2026)},
        {"key": "mid", "updated_at": now.replace(year=2025)},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Default sort is updated_at desc, so newest first
    assert keys == ["new", "mid", "old"]


def test_list_sort_by_updated_at_asc(tmp_path, monkeypatch):
    """Test that --order asc reverses the sort order."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [
        {"key": "old", "updated_at": now.replace(year=2024)},
        {"key": "new", "updated_at": now.replace(year=2026)},
        {"key": "mid", "updated_at": now.replace(year=2025)},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--order", "asc", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Ascending order, oldest first
    assert keys == ["old", "mid", "new"]


def test_list_sort_by_key_asc(tmp_path, monkeypatch):
    """Test that --sort key sorts alphabetically ascending by default."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    items_data = [
        {"key": "zebra"},
        {"key": "apple"},
        {"key": "mango"},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--sort", "key", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Text field defaults to ascending
    assert keys == ["apple", "mango", "zebra"]


def test_list_sort_by_key_desc(tmp_path, monkeypatch):
    """Test that --sort key --order desc reverses alphabetical order."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    items_data = [
        {"key": "zebra"},
        {"key": "apple"},
        {"key": "mango"},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--sort", "key", "--order", "desc", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    assert keys == ["zebra", "mango", "apple"]


def test_list_sort_by_title_case_insensitive(tmp_path, monkeypatch):
    """Test that title sorting is case-insensitive."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    items_data = [
        {"key": "k1", "title": "Zebra"},
        {"key": "k2", "title": "apple"},
        {"key": "k3", "title": "Mango"},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--sort", "title", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Case-insensitive: apple < Mango < Zebra
    assert keys == ["k2", "k3", "k1"]


def test_list_sort_items_without_dates_go_last(tmp_path, monkeypatch):
    """Test that items without dates sort to the end."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [
        {"key": "no-date"},  # No updated_at
        {"key": "has-date", "updated_at": now},
        {"key": "also-no-date"},  # No updated_at
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Item with date comes first, items without dates are sorted by key at end
    assert keys[0] == "has-date"
    # No-date items sorted by key (ascending tiebreaker)
    assert set(keys[1:]) == {"also-no-date", "no-date"}


def test_list_sort_with_limit(tmp_path, monkeypatch):
    """Test that sorting happens before limit is applied."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [
        {"key": "old", "updated_at": now.replace(year=2024)},
        {"key": "new", "updated_at": now.replace(year=2026)},
        {"key": "mid", "updated_at": now.replace(year=2025)},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--limit", "2", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Sort by updated_at desc first, then take limit 2
    assert keys == ["new", "mid"]


def test_list_json_includes_date_fields(tmp_path, monkeypatch):
    """Test that JSON output includes updated_at and created_at fields."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [
        {
            "key": "item1",
            "updated_at": now,
            "created_at": now.replace(year=2024),
        },
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    item = data["items"][0]
    assert "updated_at" in item
    assert "created_at" in item
    assert item["updated_at"] is not None
    assert item["created_at"] is not None


def test_list_sort_created_at(tmp_path, monkeypatch):
    """Test that --sort created_at sorts by creation date."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [
        {"key": "old", "created_at": now.replace(year=2024)},
        {"key": "new", "created_at": now.replace(year=2026)},
        {"key": "mid", "created_at": now.replace(year=2025)},
    ]
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--sort", "created_at", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    keys = [item["item_key"] for item in data["items"]]
    # Date field defaults to descending
    assert keys == ["new", "mid", "old"]


# =============================
# Cursor-based Pagination Tests
# =============================


class PaginationTestClient(RecordingClient):
    """Test client that supports pagination for testing cursor behavior."""

    def __init__(self, items_data: list[dict], page_size: int = 2) -> None:
        super().__init__()
        self._items_data = items_data
        self._page_size = page_size

    def list_items(self, project_key: str, *, limit: int | None = None, cursor: str | None = None):
        try:
            from shared import ItemListResult, ItemSummary
        except ImportError:
            from cli._vendor.shared import ItemListResult, ItemSummary

        # Build all summaries sorted by updated_at desc
        all_summaries = []
        for item in self._items_data:
            all_summaries.append(
                ItemSummary(
                    project_key=project_key,
                    key=item["key"],
                    title=item.get("title", item["key"]),
                    snippet=item.get("snippet", "..."),
                    tags=item.get("tags", []),
                    processing_status=item.get("processing_status"),
                    updated_at=item.get("updated_at"),
                    created_at=item.get("created_at"),
                )
            )
        # Sort by updated_at DESC
        all_summaries.sort(
            key=lambda x: (x.updated_at or datetime.min.replace(tzinfo=timezone.utc), x.key),
            reverse=True,
        )

        # Apply cursor if provided (simple index-based for testing)
        start_idx = 0
        if cursor is not None:
            # Cursor is just the index in this test implementation
            start_idx = int(cursor)

        effective_limit = limit if limit is not None else self._page_size
        end_idx = start_idx + effective_limit
        page_items = all_summaries[start_idx:end_idx]

        has_more = end_idx < len(all_summaries)
        next_cursor = str(end_idx) if has_more else None

        return ItemListResult(items=page_items, next_cursor=next_cursor, has_more=has_more)


def test_list_cursor_requires_json(tmp_path, monkeypatch):
    """Test that --cursor requires --json flag."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--cursor", "abc123"])

    # Exit code 1 indicates CLI error
    assert result.exit_code == 1


def test_list_limit_and_no_limit_mutual_exclusion(tmp_path, monkeypatch):
    """Test that --limit and --no-limit cannot be used together."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--limit", "10", "--no-limit"])

    # Exit code 1 indicates CLI error
    assert result.exit_code == 1


def test_list_with_cursor_returns_next_page(tmp_path, monkeypatch):
    """Test that --cursor returns the next page of results."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [{"key": f"item-{i}", "updated_at": now.replace(hour=i)} for i in range(5)]
    client = PaginationTestClient(items_data, page_size=2)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()

    # Get first page
    result1 = runner.invoke(cli_main.app, ["ls", "--limit", "2", "--json"])
    assert result1.exit_code == 0
    import json

    data1 = json.loads(result1.output)
    assert len(data1["items"]) == 2
    assert data1["has_more"] is True
    next_cursor = data1["next_cursor"]
    assert next_cursor is not None

    # Get second page using cursor - also need to pass limit
    result2 = runner.invoke(cli_main.app, ["ls", "--cursor", next_cursor, "--limit", "2", "--json"])
    assert result2.exit_code == 0
    data2 = json.loads(result2.output)
    assert len(data2["items"]) == 2

    # Keys should be different between pages
    keys1 = {item["item_key"] for item in data1["items"]}
    keys2 = {item["item_key"] for item in data2["items"]}
    assert keys1.isdisjoint(keys2)


def test_list_json_includes_has_more(tmp_path, monkeypatch):
    """Test that JSON output includes has_more field."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    items_data = [{"key": "single-item", "updated_at": now}]
    client = PaginationTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    assert "has_more" in data
    assert data["has_more"] is False


def test_list_no_limit_returns_all(tmp_path, monkeypatch):
    """Test that --no-limit returns all items without pagination."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    # Create more items than default limit
    items_data = [{"key": f"item-{i}", "updated_at": now.replace(minute=i)} for i in range(60)]

    # SortingTestClient doesn't apply limit when limit is None
    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--no-limit", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    # Should return all 60 items
    assert len(data["items"]) == 60


def test_list_default_limit_applied(tmp_path, monkeypatch):
    """Test that default limit of 50 is applied when no limit specified."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    cli_main = _reload_cli_module()

    now = datetime.now(tz=timezone.utc)
    # Create more items than default limit (50)
    items_data = [{"key": f"item-{i}", "updated_at": now.replace(minute=i)} for i in range(60)]

    client = SortingTestClient(items_data)
    cli_main._context.client_factory = StubFactory(client)

    store = ConfigStore()
    store.save(CLIConfig(default_project="core", use_mock_client=True))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["ls", "--json"])

    assert result.exit_code == 0
    import json

    data = json.loads(result.output)
    # Should return exactly 50 items (default limit)
    assert len(data["items"]) == 50
    assert data["has_more"] is True
