# Spike 3 Implementation Plan — API Hardening, Live Dashboard, One-Shot Install

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Harden the MCP API from multi-client battle testing, add live SSE updates to the dashboard, improve watcher resilience, and provide a one-command install experience.

**Architecture:** Six independent items that can be implemented in sequence. Items 1-2 are quick fixes to server.py. Item 3 adds an event bus and SSE endpoint. Item 4 wraps watcher calls in error handling. Item 5 adds a new MCP tool and store method. Item 6 creates a new CLI module for install/uninstall.

**Tech Stack:** Python 3.12, FastMCP, ChromaDB, Starlette (SSE via StreamingResponse), HTMX SSE extension, Jinja2, systemd/launchd/Windows scheduled tasks.

---

### Task 1: Tags input normalization

**Files:**
- Modify: `src/annal/server.py:140-165` (store_memory, search_memories tools)
- Test: `tests/test_server.py`

**Step 1: Write the failing test**

Add to `tests/test_server.py`:

```python
@pytest.mark.asyncio
async def test_store_memory_accepts_string_tags(mcp):
    """Tags param should accept a bare string and coerce to list."""
    result = await _call(mcp, "store_memory", {
        "project": "test",
        "content": "String tags should work",
        "tags": "decision",
    })
    assert "Stored memory" in result


@pytest.mark.asyncio
async def test_search_memories_accepts_string_tags(mcp):
    """Tags filter should accept a bare string and coerce to list."""
    await _call(mcp, "store_memory", {
        "project": "test",
        "content": "Tagged content for search",
        "tags": ["searchable"],
    })
    result = await _call(mcp, "search_memories", {
        "project": "test",
        "query": "tagged content",
        "tags": "searchable",
    })
    assert "Tagged content for search" in result


@pytest.mark.asyncio
async def test_store_memory_lowercases_and_dedupes_tags(mcp):
    """Tags should be lowercased and deduped on store."""
    result = await _call(mcp, "store_memory", {
        "project": "test",
        "content": "Normalized tags test",
        "tags": ["Decision", "BILLING", "decision"],
    })
    mem_id = result.split("Stored memory ")[-1].strip()
    expanded = await _call(mcp, "expand_memories", {
        "project": "test",
        "memory_ids": [mem_id],
    })
    # Should have lowercase, deduped tags
    assert "decision" in expanded
    assert "billing" in expanded
    assert "Decision" not in expanded
```

**Step 2: Run tests to verify they fail**

Run: `python3 -m pytest tests/test_server.py::test_store_memory_accepts_string_tags tests/test_server.py::test_search_memories_accepts_string_tags tests/test_server.py::test_store_memory_lowercases_and_dedupes_tags -v`
Expected: FAIL — FastMCP validation rejects string for list[str] param.

**Step 3: Implement tags normalization**

In `src/annal/server.py`, add a helper function after the imports and before `create_server`:

```python
def _normalize_tags(tags: list[str] | str | None) -> list[str] | None:
    """Coerce tags to a deduplicated lowercase list."""
    if tags is None:
        return None
    if isinstance(tags, str):
        tags = [tags]
    seen: set[str] = set()
    result: list[str] = []
    for t in tags:
        lower = t.strip().lower()
        if lower and lower not in seen:
            seen.add(lower)
            result.append(lower)
    return result
```

Then change the type annotations and add normalization calls:

In `store_memory` (line ~140): Change `tags: list[str]` to `tags: list[str] | str`. Add `tags = _normalize_tags(tags)` as the first line of the function body.

In `search_memories` (line ~171): Change `tags: list[str] | None = None` to `tags: list[str] | str | None = None`. Add `tags = _normalize_tags(tags)` as the first line.

**Step 4: Run tests to verify they pass**

Run: `python3 -m pytest tests/test_server.py -v`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/annal/server.py tests/test_server.py
git commit -m "feat: normalize tags input — accept string, lowercase, dedupe"
```

---

### Task 2: Default min_score cutoff

**Files:**
- Modify: `src/annal/server.py:167-212` (search_memories tool)
- Test: `tests/test_server.py`

**Step 1: Write the failing test**

Add to `tests/test_server.py`:

```python
@pytest.mark.asyncio
async def test_search_suppresses_negative_scores(mcp):
    """Default min_score=0.0 should suppress negative-score results."""
    await _call(mcp, "store_memory", {
        "project": "test",
        "content": "Billing uses tax-inclusive rounding on gross amounts",
        "tags": ["billing"],
    })
    # Search for something completely unrelated
    result = await _call(mcp, "search_memories", {
        "project": "test",
        "query": "kubernetes deployment strategy",
    })
    # The billing memory should NOT appear since its score will be near zero or negative
    # for a completely unrelated query. If it does appear, score filtering isn't working.
    # Note: with only one memory in the store, ChromaDB will return it regardless of relevance.
    # So we test with min_score explicitly set high.
    result_filtered = await _call(mcp, "search_memories", {
        "project": "test",
        "query": "kubernetes deployment strategy",
        "min_score": 0.5,
    })
    assert "No matching memories found" in result_filtered


@pytest.mark.asyncio
async def test_search_min_score_zero_allows_positive(mcp):
    """min_score=0.0 (default) should still return relevant results."""
    await _call(mcp, "store_memory", {
        "project": "test",
        "content": "Use pytest for all testing, no unittest",
        "tags": ["testing"],
    })
    result = await _call(mcp, "search_memories", {
        "project": "test",
        "query": "what testing framework do we use",
    })
    assert "pytest" in result
```

**Step 2: Run tests to verify they fail**

Run: `python3 -m pytest tests/test_server.py::test_search_suppresses_negative_scores tests/test_server.py::test_search_min_score_zero_allows_positive -v`
Expected: FAIL — `min_score` parameter doesn't exist yet.

**Step 3: Implement min_score filtering**

In `src/annal/server.py`, modify `search_memories`:

Add `min_score: float = 0.0` parameter to the function signature.

Add to the docstring: `min_score: Minimum similarity score to include (default 0.0, suppresses negative scores)`

After `results = store.search(...)` and before the formatting loop, add:

```python
results = [r for r in results if r["score"] >= min_score]
if not results:
    return f"[{project}] No matching memories found."
```

**Step 4: Run tests to verify they pass**

Run: `python3 -m pytest tests/test_server.py -v`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/annal/server.py tests/test_server.py
git commit -m "feat: add min_score param to search_memories, suppress negatives by default"
```

---

### Task 3: Watcher resilience

**Files:**
- Modify: `src/annal/watcher.py:75-88` (_IndexHandler methods), `src/annal/watcher.py:97-125` (reconcile method)
- Test: `tests/test_watcher.py`

**Step 1: Write the failing test**

Add to `tests/test_watcher.py`:

```python
def test_reconcile_survives_unreadable_file(tmp_data_dir, tmp_path):
    """A file that raises on read should not crash reconciliation."""
    from annal.store import MemoryStore
    from annal.config import ProjectConfig

    # Create a valid file and an unreadable file
    good_file = tmp_path / "good.md"
    good_file.write_text("# Good\nThis is fine.")
    bad_file = tmp_path / "bad.md"
    bad_file.write_text("# Bad")
    bad_file.chmod(0o000)

    store = MemoryStore(data_dir=tmp_data_dir, project="resilience_test")
    config = ProjectConfig(
        watch_paths=[str(tmp_path)],
        watch_patterns=["**/*.md"],
        watch_exclude=[],
    )
    watcher = FileWatcher(store=store, project_config=config)
    count = watcher.reconcile()

    # Should have indexed the good file, skipped the bad one, not crashed
    assert count >= 1

    # Clean up permissions so tmp_path cleanup works
    bad_file.chmod(0o644)
```

**Step 2: Run test to verify it fails**

Run: `python3 -m pytest tests/test_watcher.py::test_reconcile_survives_unreadable_file -v`
Expected: FAIL — PermissionError crashes reconciliation.

**Step 3: Wrap watcher calls in error handling**

In `src/annal/watcher.py`, modify `_IndexHandler`:

```python
def on_modified(self, event: FileModifiedEvent) -> None:
    if not event.is_directory and self._should_index(event.src_path):
        try:
            logger.info("File modified, re-indexing: %s", event.src_path)
            index_file(self._store, event.src_path)
        except Exception:
            logger.exception("Failed to index modified file: %s", event.src_path)

def on_created(self, event: FileCreatedEvent) -> None:
    if not event.is_directory and self._should_index(event.src_path):
        try:
            logger.info("File created, indexing: %s", event.src_path)
            index_file(self._store, event.src_path)
        except Exception:
            logger.exception("Failed to index created file: %s", event.src_path)

def on_deleted(self, event: FileDeletedEvent) -> None:
    if not event.is_directory and self._should_index(event.src_path):
        try:
            logger.info("File deleted, removing from store: %s", event.src_path)
            self._store.delete_by_source(f"file:{event.src_path}")
        except Exception:
            logger.exception("Failed to handle deleted file: %s", event.src_path)
```

In `reconcile()`, wrap the per-file block in the `for path in root.rglob("*")` loop:

```python
for path in root.rglob("*"):
    if path.is_dir():
        continue
    try:
        rel = str(path.relative_to(root))
        if not matches_patterns(rel, self._config.watch_patterns, self._config.watch_exclude):
            continue

        file_path = str(path)
        current_mtime = path.stat().st_mtime
        stored_mtime = self._store.get_file_mtime(f"file:{file_path}")

        if stored_mtime is not None and stored_mtime == current_mtime:
            skipped += 1
            continue

        index_file(self._store, file_path, file_mtime=current_mtime)
        total += 1
    except Exception:
        logger.exception("Failed to process file: %s", path)
```

**Step 4: Run tests to verify they pass**

Run: `python3 -m pytest tests/test_watcher.py -v`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/annal/watcher.py tests/test_watcher.py
git commit -m "fix: watcher resilience — catch per-file errors, log and continue"
```

---

### Task 4: update_memory tool

**Files:**
- Modify: `src/annal/store.py` (add update method)
- Modify: `src/annal/server.py` (add update_memory tool)
- Test: `tests/test_store.py`, `tests/test_server.py`

**Step 1: Write the failing store test**

Add to `tests/test_store.py`:

```python
def test_update_memory_content(tmp_data_dir):
    store = MemoryStore(data_dir=tmp_data_dir, project="update_test")
    mem_id = store.store(content="Original content", tags=["test"])

    store.update(mem_id, content="Updated content")

    results = store.get_by_ids([mem_id])
    assert len(results) == 1
    assert results[0]["content"] == "Updated content"
    assert results[0]["tags"] == ["test"]  # tags unchanged
    assert "updated_at" in results[0]


def test_update_memory_tags(tmp_data_dir):
    store = MemoryStore(data_dir=tmp_data_dir, project="update_test")
    mem_id = store.store(content="Some content", tags=["old-tag"])

    store.update(mem_id, tags=["new-tag", "extra"])

    results = store.get_by_ids([mem_id])
    assert results[0]["tags"] == ["new-tag", "extra"]
    assert results[0]["content"] == "Some content"  # content unchanged
```

**Step 2: Run tests to verify they fail**

Run: `python3 -m pytest tests/test_store.py::test_update_memory_content tests/test_store.py::test_update_memory_tags -v`
Expected: FAIL — `store.update` doesn't exist.

**Step 3: Implement store.update()**

Add to `src/annal/store.py` after the `delete_many` method:

```python
def update(
    self,
    mem_id: str,
    content: str | None = None,
    tags: list[str] | None = None,
    source: str | None = None,
) -> None:
    """Update an existing memory's content, tags, and/or source in place."""
    # Fetch current state
    current = self._collection.get(ids=[mem_id], include=["documents", "metadatas"])
    if not current["ids"]:
        raise ValueError(f"Memory {mem_id} not found")

    old_meta = current["metadatas"][0]
    old_doc = current["documents"][0]

    new_doc = content if content is not None else old_doc
    new_meta = dict(old_meta)
    new_meta["updated_at"] = datetime.now(timezone.utc).isoformat()

    if tags is not None:
        new_meta["tags"] = json.dumps(tags)
    if source is not None:
        new_meta["source"] = source

    self._collection.update(
        ids=[mem_id],
        documents=[new_doc],
        metadatas=[new_meta],
    )
```

Also update `get_by_ids` to include `updated_at` in returned dicts (add after `"created_at"` line):

```python
"updated_at": meta.get("updated_at", ""),
```

**Step 4: Run store tests to verify they pass**

Run: `python3 -m pytest tests/test_store.py -v`
Expected: ALL PASS

**Step 5: Write the failing server test**

Add to `tests/test_server.py`:

```python
@pytest.mark.asyncio
async def test_update_memory(mcp):
    result = await _call(mcp, "store_memory", {
        "project": "test",
        "content": "Original decision about auth",
        "tags": ["decision", "auth"],
    })
    mem_id = result.split("Stored memory ")[-1].strip()

    update_result = await _call(mcp, "update_memory", {
        "project": "test",
        "memory_id": mem_id,
        "content": "Revised decision: use JWT not sessions",
        "tags": ["decision", "auth", "jwt"],
    })
    assert "Updated memory" in update_result

    expanded = await _call(mcp, "expand_memories", {
        "project": "test",
        "memory_ids": [mem_id],
    })
    assert "Revised decision" in expanded
    assert "jwt" in expanded
```

**Step 6: Run test to verify it fails**

Run: `python3 -m pytest tests/test_server.py::test_update_memory -v`
Expected: FAIL — tool doesn't exist.

**Step 7: Implement update_memory tool**

Add to `src/annal/server.py` after the `delete_memory` tool:

```python
@mcp.tool()
def update_memory(
    project: str,
    memory_id: str,
    content: str | None = None,
    tags: list[str] | str | None = None,
    source: str | None = None,
) -> str:
    """Update an existing memory's content, tags, or source without losing its ID.

    Args:
        project: Project name the memory belongs to
        memory_id: The ID of the memory to update
        content: New content (omit to keep existing)
        tags: New tags (omit to keep existing)
        source: New source (omit to keep existing)
    """
    if content is None and tags is None and source is None:
        return f"[{project}] Nothing to update — provide content, tags, or source."
    store = pool.get_store(project)
    normalized_tags = _normalize_tags(tags) if tags is not None else None
    store.update(memory_id, content=content, tags=normalized_tags, source=source)
    return f"[{project}] Updated memory {memory_id}"
```

**Step 8: Run all tests**

Run: `python3 -m pytest tests/test_server.py tests/test_store.py -v`
Expected: ALL PASS

**Step 9: Commit**

```bash
git add src/annal/store.py src/annal/server.py tests/test_store.py tests/test_server.py
git commit -m "feat: update_memory tool — revise content/tags/source in place"
```

---

### Task 5: Dashboard SSE live updates

**Files:**
- Create: `src/annal/events.py`
- Modify: `src/annal/server.py` (push events from tools)
- Modify: `src/annal/dashboard/routes.py` (SSE endpoint)
- Modify: `src/annal/dashboard/templates/base.html` (HTMX SSE extension)
- Modify: `src/annal/dashboard/templates/memories.html` (SSE listener on table)
- Modify: `src/annal/dashboard/static/style.css` (activity indicator styles)
- Test: `tests/test_dashboard.py`

**Step 1: Create the event bus module**

Create `src/annal/events.py`:

```python
"""Simple in-process event bus for dashboard live updates."""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


@dataclass
class Event:
    """A dashboard event."""
    type: str      # "memory_stored", "memory_deleted", "index_started", "index_complete"
    project: str
    detail: str = ""


class EventBus:
    """Fan-out event bus: push to all connected SSE clients."""

    def __init__(self) -> None:
        self._queues: list[asyncio.Queue[Event]] = []

    def subscribe(self) -> asyncio.Queue[Event]:
        """Create a new subscription queue for an SSE client."""
        q: asyncio.Queue[Event] = asyncio.Queue()
        self._queues.append(q)
        return q

    def unsubscribe(self, q: asyncio.Queue[Event]) -> None:
        """Remove a subscription queue."""
        try:
            self._queues.remove(q)
        except ValueError:
            pass

    def push(self, event: Event) -> None:
        """Push an event to all subscribers. Safe to call from any thread."""
        for q in list(self._queues):
            try:
                q.put_nowait(event)
            except asyncio.QueueFull:
                logger.warning("SSE client queue full, dropping event")


# Singleton instance shared between server.py and dashboard routes
event_bus = EventBus()
```

**Step 2: Write the failing SSE test**

Add to `tests/test_dashboard.py`:

```python
import asyncio
from annal.events import event_bus, Event


def test_sse_endpoint_exists(client):
    """The /events SSE endpoint should exist and return text/event-stream."""
    # We can't easily test a streaming response in the test client,
    # but we can verify the route exists and returns the right content type
    response = client.get("/events")
    assert response.status_code == 200
    assert "text/event-stream" in response.headers.get("content-type", "")


def test_event_bus_pub_sub():
    """Events pushed to the bus should be received by subscribers."""
    loop = asyncio.new_event_loop()
    q = event_bus.subscribe()
    event_bus.push(Event(type="memory_stored", project="test", detail="test memory"))
    try:
        received = loop.run_until_complete(asyncio.wait_for(q.get(), timeout=1.0))
        assert received.type == "memory_stored"
        assert received.project == "test"
    finally:
        event_bus.unsubscribe(q)
        loop.close()
```

**Step 3: Run tests to verify they fail**

Run: `python3 -m pytest tests/test_dashboard.py::test_sse_endpoint_exists tests/test_dashboard.py::test_event_bus_pub_sub -v`
Expected: FAIL — module and endpoint don't exist.

**Step 4: Add SSE endpoint to dashboard routes**

In `src/annal/dashboard/routes.py`, add import at top:

```python
import asyncio
from starlette.responses import StreamingResponse
from annal.events import event_bus
```

Add SSE route inside `create_routes()`, before the `return`:

```python
async def events(request: Request) -> Response:
    """SSE endpoint for live dashboard updates."""
    queue = event_bus.subscribe()

    async def generate():
        try:
            while True:
                event = await queue.get()
                yield f"event: {event.type}\ndata: {event.project}|{event.detail}\n\n"
        except asyncio.CancelledError:
            pass
        finally:
            event_bus.unsubscribe(queue)

    return StreamingResponse(generate(), media_type="text/event-stream", headers={
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
    })
```

Add the route to the returned list:

```python
Route("/events", events),
```

**Step 5: Wire events into server.py**

In `src/annal/server.py`, add import:

```python
from annal.events import event_bus, Event
```

At the end of `store_memory` (on the success path, after `store.store()`):

```python
event_bus.push(Event(type="memory_stored", project=project, detail=mem_id))
```

At the end of `delete_memory`:

```python
event_bus.push(Event(type="memory_deleted", project=project, detail=memory_id))
```

At the start/end of `index_files`:

```python
# At start, before delete_by_source:
event_bus.push(Event(type="index_started", project=project))

# At end, before return:
event_bus.push(Event(type="index_complete", project=project, detail=f"{count} files"))
```

**Step 6: Add HTMX SSE extension to templates**

In `src/annal/dashboard/templates/base.html`, add the SSE extension script after the HTMX script:

```html
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
```

Add an activity indicator in the nav, after the nav-links div:

```html
<div class="activity-indicator" id="activity-indicator"></div>
```

In `src/annal/dashboard/templates/memories.html`, add the SSE listener. Wrap the table body in an SSE-connected div by adding `hx-ext="sse"` and `sse-connect="/events"` to the table element, and add a listener that triggers a table refresh on any memory event:

```html
<div hx-ext="sse" sse-connect="/events">
  <div sse-swap="memory_stored"
       hx-get="/memories/table"
       hx-target="#memory-table"
       hx-include="[name]"
       hx-trigger="sse:memory_stored, sse:memory_deleted, sse:index_complete"
       style="display:none"></div>
</div>
```

Add a script block to handle the activity indicator:

```javascript
document.body.addEventListener('sse:index_started', function() {
  document.getElementById('activity-indicator').textContent = 'indexing…';
  document.getElementById('activity-indicator').classList.add('active');
});
document.body.addEventListener('sse:index_complete', function() {
  document.getElementById('activity-indicator').textContent = '';
  document.getElementById('activity-indicator').classList.remove('active');
});
```

**Step 7: Add activity indicator CSS**

In `src/annal/dashboard/static/style.css`, add after the `.nav-links` styles:

```css
.activity-indicator {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--accent);
  opacity: 0;
  transition: opacity 0.3s;
}

.activity-indicator.active {
  opacity: 1;
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 1; }
}
```

**Step 8: Run tests**

Run: `python3 -m pytest tests/test_dashboard.py -v`
Expected: ALL PASS

**Step 9: Commit**

```bash
git add src/annal/events.py src/annal/server.py src/annal/dashboard/routes.py \
  src/annal/dashboard/templates/base.html src/annal/dashboard/templates/memories.html \
  src/annal/dashboard/static/style.css tests/test_dashboard.py
git commit -m "feat: dashboard SSE live updates — real-time memory and indexing events"
```

---

### Task 6: annal install — one-shot setup

**Files:**
- Create: `src/annal/cli.py`
- Modify: `src/annal/server.py:349-385` (main function — add install subcommand)
- Modify: `pyproject.toml:43-44` (entry point)
- Test: `tests/test_cli.py`

**Step 1: Write the failing test**

Create `tests/test_cli.py`:

```python
"""Tests for the annal install/uninstall CLI."""

import json
import os
from pathlib import Path
from unittest.mock import patch

import pytest

from annal.cli import install, uninstall


@pytest.fixture
def fake_home(tmp_path):
    """Set up a fake home directory with expected client config dirs."""
    home = tmp_path / "home"
    home.mkdir()
    (home / ".claude").mkdir()
    (home / ".codex").mkdir()
    (home / ".gemini").mkdir()

    # Codex needs a config.toml to exist
    (home / ".codex" / "config.toml").write_text('model = "gpt-5.3-codex"\n')
    # Gemini needs a settings.json to exist
    (home / ".gemini" / "settings.json").write_text('{}')

    return home


def test_install_creates_config(fake_home):
    with patch("annal.cli.Path.home", return_value=fake_home):
        result = install(start_service=False)

    assert "config.yaml" in result
    config_path = fake_home / ".annal" / "config.yaml"
    assert config_path.exists()


def test_install_creates_mcp_json(fake_home):
    with patch("annal.cli.Path.home", return_value=fake_home):
        install(start_service=False)

    mcp_json = fake_home / ".mcp.json"
    assert mcp_json.exists()
    data = json.loads(mcp_json.read_text())
    assert "annal" in data["mcpServers"]
    assert data["mcpServers"]["annal"]["url"] == "http://localhost:9200/mcp"


def test_install_configures_codex(fake_home):
    with patch("annal.cli.Path.home", return_value=fake_home):
        install(start_service=False)

    config = (fake_home / ".codex" / "config.toml").read_text()
    assert "[mcp_servers.annal]" in config
    assert "http://127.0.0.1:9200/mcp" in config


def test_install_configures_gemini(fake_home):
    with patch("annal.cli.Path.home", return_value=fake_home):
        install(start_service=False)

    data = json.loads((fake_home / ".gemini" / "settings.json").read_text())
    assert "annal" in data["mcpServers"]


def test_install_skips_missing_clients(tmp_path):
    """Should not crash if a client dir doesn't exist."""
    home = tmp_path / "home"
    home.mkdir()
    with patch("annal.cli.Path.home", return_value=home):
        result = install(start_service=False)
    assert "config.yaml" in result


def test_uninstall_removes_mcp_json_entry(fake_home):
    # First install
    with patch("annal.cli.Path.home", return_value=fake_home):
        install(start_service=False)
        uninstall(stop_service=False)

    mcp_json = fake_home / ".mcp.json"
    if mcp_json.exists():
        data = json.loads(mcp_json.read_text())
        assert "annal" not in data.get("mcpServers", {})
```

**Step 2: Run tests to verify they fail**

Run: `python3 -m pytest tests/test_cli.py -v`
Expected: FAIL — `annal.cli` module doesn't exist.

**Step 3: Implement cli.py**

Create `src/annal/cli.py`:

```python
"""Annal CLI — install/uninstall one-shot setup."""

from __future__ import annotations

import json
import logging
import platform
import shutil
import subprocess
import sys
from pathlib import Path

import yaml

from annal.config import DEFAULT_WATCH_EXCLUDE, DEFAULT_WATCH_PATTERNS

logger = logging.getLogger(__name__)

MCP_URL = "http://localhost:9200/mcp"
INTERNAL_URL = "http://127.0.0.1:9200/mcp"


def _annal_executable() -> str:
    """Find the annal executable path."""
    # Prefer the one in the same venv as the running Python
    venv_bin = Path(sys.executable).parent / "annal"
    if venv_bin.exists():
        return str(venv_bin)
    # Fall back to PATH lookup
    found = shutil.which("annal")
    if found:
        return found
    # Last resort: python -m annal.server
    return f"{sys.executable} -m annal.server"


def install(start_service: bool = True) -> str:
    """One-shot install: config, OS service, MCP client configs."""
    home = Path.home()
    actions: list[str] = []

    # 1. Create ~/.annal/config.yaml if missing
    annal_dir = home / ".annal"
    config_path = annal_dir / "config.yaml"
    if not config_path.exists():
        annal_dir.mkdir(parents=True, exist_ok=True)
        config_data = {
            "data_dir": str(annal_dir / "data"),
            "port": 9200,
            "projects": {},
        }
        with open(config_path, "w") as f:
            yaml.dump(config_data, f, default_flow_style=False)
        actions.append(f"Created config.yaml at {config_path}")
    else:
        actions.append(f"config.yaml already exists at {config_path}")

    # 2. Configure Claude Code (~/.mcp.json)
    mcp_json = home / ".mcp.json"
    mcp_data: dict = {}
    if mcp_json.exists():
        mcp_data = json.loads(mcp_json.read_text())
    if "mcpServers" not in mcp_data:
        mcp_data["mcpServers"] = {}
    if "annal" not in mcp_data["mcpServers"]:
        mcp_data["mcpServers"]["annal"] = {"type": "http", "url": MCP_URL}
        mcp_json.write_text(json.dumps(mcp_data, indent=2) + "\n")
        actions.append("Configured Claude Code (~/.mcp.json)")
    else:
        actions.append("Claude Code already configured")

    # 3. Configure Codex (~/.codex/config.toml)
    codex_config = home / ".codex" / "config.toml"
    if codex_config.exists():
        content = codex_config.read_text()
        if "[mcp_servers.annal]" not in content:
            content += f'\n[mcp_servers.annal]\nurl = "{INTERNAL_URL}"\n'
            codex_config.write_text(content)
            actions.append("Configured Codex (~/.codex/config.toml)")
        else:
            actions.append("Codex already configured")
    else:
        actions.append("Codex not found, skipped")

    # 4. Configure Gemini (~/.gemini/settings.json)
    gemini_config = home / ".gemini" / "settings.json"
    if gemini_config.exists():
        gemini_data = json.loads(gemini_config.read_text())
        if "mcpServers" not in gemini_data:
            gemini_data["mcpServers"] = {}
        if "annal" not in gemini_data["mcpServers"]:
            gemini_data["mcpServers"]["annal"] = {"httpUrl": INTERNAL_URL}
            gemini_config.write_text(json.dumps(gemini_data, indent=2) + "\n")
            actions.append("Configured Gemini (~/.gemini/settings.json)")
        else:
            actions.append("Gemini already configured")
    else:
        actions.append("Gemini not found, skipped")

    # 5. Install OS service
    os_name = platform.system()
    exe = _annal_executable()

    if os_name == "Linux":
        service_dir = home / ".config" / "systemd" / "user"
        service_dir.mkdir(parents=True, exist_ok=True)
        service_file = service_dir / "annal.service"
        service_file.write_text(f"""\
[Unit]
Description=Annal semantic memory MCP server
After=network.target

[Service]
Type=simple
ExecStart={exe} --transport streamable-http
Restart=always
RestartSec=3
Environment=PYTHONUNBUFFERED=1
KillSignal=SIGINT

[Install]
WantedBy=default.target
""")
        actions.append(f"Installed systemd service at {service_file}")
        if start_service:
            subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
            subprocess.run(["systemctl", "--user", "enable", "annal.service"], check=False)
            subprocess.run(["systemctl", "--user", "start", "annal.service"], check=False)
            actions.append("Started annal.service")

    elif os_name == "Darwin":
        plist_dir = home / "Library" / "LaunchAgents"
        plist_dir.mkdir(parents=True, exist_ok=True)
        plist_file = plist_dir / "com.annal.server.plist"
        plist_file.write_text(f"""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" \
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key><string>com.annal.server</string>
    <key>ProgramArguments</key>
    <array>
        <string>{exe}</string>
        <string>--transport</string>
        <string>streamable-http</string>
    </array>
    <key>RunAtLoad</key><true/>
    <key>KeepAlive</key><true/>
    <key>StandardOutPath</key><string>/tmp/annal.stdout.log</string>
    <key>StandardErrorPath</key><string>/tmp/annal.stderr.log</string>
    <key>EnvironmentVariables</key>
    <dict><key>PYTHONUNBUFFERED</key><string>1</string></dict>
</dict>
</plist>
""")
        actions.append(f"Installed launchd plist at {plist_file}")
        if start_service:
            subprocess.run(["launchctl", "load", str(plist_file)], check=False)
            actions.append("Loaded launchd agent")

    elif os_name == "Windows":
        actions.append("Windows: run contrib/annal-service.ps1 manually for now")

    else:
        actions.append(f"Unknown OS '{os_name}', skipped service install")

    return "Annal installed:\n" + "\n".join(f"  - {a}" for a in actions)


def uninstall(stop_service: bool = True) -> str:
    """Remove Annal service and MCP client configs."""
    home = Path.home()
    actions: list[str] = []

    # Remove MCP client configs
    mcp_json = home / ".mcp.json"
    if mcp_json.exists():
        data = json.loads(mcp_json.read_text())
        if "annal" in data.get("mcpServers", {}):
            del data["mcpServers"]["annal"]
            mcp_json.write_text(json.dumps(data, indent=2) + "\n")
            actions.append("Removed from Claude Code (~/.mcp.json)")

    codex_config = home / ".codex" / "config.toml"
    if codex_config.exists():
        content = codex_config.read_text()
        if "[mcp_servers.annal]" in content:
            lines = content.split("\n")
            new_lines = []
            skip = False
            for line in lines:
                if line.strip() == "[mcp_servers.annal]":
                    skip = True
                    continue
                if skip and (line.startswith("[") or line.strip() == ""):
                    skip = False
                if not skip:
                    new_lines.append(line)
            codex_config.write_text("\n".join(new_lines))
            actions.append("Removed from Codex (~/.codex/config.toml)")

    gemini_config = home / ".gemini" / "settings.json"
    if gemini_config.exists():
        data = json.loads(gemini_config.read_text())
        if "annal" in data.get("mcpServers", {}):
            del data["mcpServers"]["annal"]
            gemini_config.write_text(json.dumps(data, indent=2) + "\n")
            actions.append("Removed from Gemini (~/.gemini/settings.json)")

    # Stop and remove OS service
    os_name = platform.system()
    if os_name == "Linux":
        if stop_service:
            subprocess.run(["systemctl", "--user", "stop", "annal.service"], check=False)
            subprocess.run(["systemctl", "--user", "disable", "annal.service"], check=False)
        service_file = home / ".config" / "systemd" / "user" / "annal.service"
        if service_file.exists():
            service_file.unlink()
            actions.append("Removed systemd service")
    elif os_name == "Darwin":
        plist_file = home / "Library" / "LaunchAgents" / "com.annal.server.plist"
        if stop_service and plist_file.exists():
            subprocess.run(["launchctl", "unload", str(plist_file)], check=False)
        if plist_file.exists():
            plist_file.unlink()
            actions.append("Removed launchd plist")

    return "Annal uninstalled:\n" + "\n".join(f"  - {a}" for a in actions) if actions else "Nothing to uninstall."
```

**Step 4: Run tests to verify they pass**

Run: `python3 -m pytest tests/test_cli.py -v`
Expected: ALL PASS

**Step 5: Wire CLI into the entry point**

In `src/annal/server.py`, modify `main()` to use argparse subcommands. Replace the current `main()` function:

```python
def main() -> None:
    """Entry point for running the server."""
    import argparse

    parser = argparse.ArgumentParser(description="Annal semantic memory server")
    subparsers = parser.add_subparsers(dest="command")

    # Default: run the server (also works with no subcommand)
    serve_parser = subparsers.add_parser("serve", help="Run the MCP server")
    serve_parser.add_argument(
        "--transport",
        choices=["stdio", "streamable-http"],
        default="stdio",
    )
    serve_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH)
    serve_parser.add_argument("--no-dashboard", action="store_true")

    # Install subcommand
    subparsers.add_parser("install", help="Install Annal service and configure MCP clients")

    # Uninstall subcommand
    subparsers.add_parser("uninstall", help="Remove Annal service and MCP client configs")

    args = parser.parse_args()

    if args.command == "install":
        from annal.cli import install
        print(install())
        return

    if args.command == "uninstall":
        from annal.cli import uninstall
        print(uninstall())
        return

    # Default: serve (handle both `annal serve` and bare `annal` with old flags)
    transport = getattr(args, "transport", "stdio")
    config_path = getattr(args, "config", DEFAULT_CONFIG_PATH)
    no_dashboard = getattr(args, "no_dashboard", False)

    config = AnnalConfig.load(config_path)
    mcp = create_server(config_path=config_path)

    if not no_dashboard:
        pool = StorePool(config)
        dashboard_port = config.port if transport == "stdio" else config.port + 1
        _start_dashboard(pool, config, port=dashboard_port)

    mcp.run(transport=transport)
```

Also add backward compat: if no subcommand is given but `--transport` is passed, treat it as `serve`. The `getattr` calls above handle this since `args.transport` etc. won't exist when using subcommands other than serve.

**Step 6: Run full test suite**

Run: `python3 -m pytest -v`
Expected: ALL PASS

**Step 7: Commit**

```bash
git add src/annal/cli.py src/annal/server.py tests/test_cli.py
git commit -m "feat: annal install — one-shot service + MCP client setup"
```

---

### Task 7: Final verification

**Step 1: Run full test suite**

Run: `python3 -m pytest -v`
Expected: ALL PASS (should be ~75+ tests)

**Step 2: Manual smoke test**

```bash
# Reinstall in venv
pip install -e ".[dev]"

# Test the install command (dry run — service already running)
annal install

# Restart service to pick up SSE changes
systemctl --user restart annal.service

# Open dashboard at http://localhost:9201
# Verify SSE connection in browser dev tools (Network tab, /events endpoint)
```

**Step 3: Commit any final fixes**

If anything needs fixing from the smoke test, fix and commit.
