from __future__ import annotations

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

from typer.testing import CliRunner

from cli.config import CACHE_DIR_ENV, CONFIG_DIR_ENV, CLIConfig, ConfigStore
from 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,
    ) -> 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._item_state: dict[str, dict] = {}
        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]] = []

    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) -> 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(
                project_key=project_key,
                key=item_key,
                title=state["title"],
                data="payload",
                mime_type=state["mime_type"],
                tags=state["tags"],
                created_at=datetime.now(tz=UTC),
                updated_at=datetime.now(tz=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=UTC),
                updated_at=datetime.now(tz=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=UTC),
            updated_at=datetime.now(tz=UTC),
        )

    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(
            project_key=project_key,
            key=item_key,
            title=title or item_key,
            data=data,
            mime_type=mime_type,
            tags=list(tags or []),
            created_at=datetime.now(tz=UTC),
            updated_at=datetime.now(tz=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) -> File:
        file_id = str(uuid4())
        self.file_calls.append(
            {
                "project_key": project_key,
                "name": name,
                "data": data,
                "mime_type": mime_type,
            }
        )
        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 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"))

    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"))

    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"))

    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_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"))

    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"))

    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"))

    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"))

    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"))

    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_mime_type_override_routes_to_files(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"))

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

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


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"))

    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_saves_new_key(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", "--rename", "fresh"],
    )

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


def test_put_conflict_rename_existing_key_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))

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

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


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"))

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

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


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"))

    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"))

    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"))

    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"))

    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) -> Item:
        item = original_get(project_key, item_key)
        return Item(
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="x" * 10000,
            mime_type=item.mime_type,
            tags=item.tags,
            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"))

    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) -> Item:
        item = original_get(project_key, item_key)
        return Item(
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="x" * 10000,
            mime_type=item.mime_type,
            tags=item.tags,
            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"))

    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"))

    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"))

    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"))

    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) -> Item:
        item = original_get(project_key, item_key)
        return Item(
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            data="",
            mime_type=item.mime_type,
            tags=item.tags,
            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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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"))

    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) -> Item:
        item = original_get(project_key, item_key)
        return Item(
            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,
            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"))

    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 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))
    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"))

    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 nothing was written to cache
    assert not cache_dir.exists() or not any(cache_dir.rglob("*"))


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"))

    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"))

    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"))

    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_put_file_tilde_expansion(tmp_path, monkeypatch):
    """Test that --file expands ~ to home directory."""
    monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path))
    monkeypatch.setenv("HOME", str(tmp_path))
    cli_main = _reload_cli_module()

    # Create a file in the fake home directory
    data_file = tmp_path / "data.txt"
    data_file.write_text("content from tilde file")

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

    store = ConfigStore()
    store.save(CLIConfig(default_project="core"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["put", "my-item", "--file", "~/data.txt"])

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


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_file_unreadable_file_shows_clear_error(tmp_path, monkeypatch):
    """Test that unreadable files with --file 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", "my-item", "--file", "~/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)


# =============================================================================
# Set Command Tests
# =============================================================================


def test_set_shows_current_metadata_without_flags(tmp_path, monkeypatch):
    """Test that set with no flags displays current metadata."""
    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"))

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

    assert result.exit_code == 0
    assert "Item:" in result.output
    assert "core/my-item" in result.output
    assert "Title:" in result.output
    assert "My Item Title" in result.output
    assert "MIME type:" in result.output
    assert "text/markdown" in result.output
    assert "Tags:" in result.output
    assert "tag1" in result.output
    assert "tag2" in result.output
    # Should not call patch
    assert client.patch_calls == []


def test_set_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"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["set", "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_set_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"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["set", "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_set_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"))

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

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


def test_set_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"))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["set", "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_set_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"))

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

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


def test_set_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"))

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

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


def test_set_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"))

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

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


def test_set_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"))

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

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


def test_set_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"))

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

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


def test_set_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"))

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

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


def test_set_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"))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        [
            "set",
            "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_set_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"))

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

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


def test_set_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"))

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

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


def test_set_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"))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["set", "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_set_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"))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["set", "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_set_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"))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app,
        ["set", "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_set_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"))

    runner = CliRunner()
    result = runner.invoke(
        cli_main.app, ["set", "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_set_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"))

    runner = CliRunner()
    result = runner.invoke(cli_main.app, ["set", "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)
