#!/usr/bin/env python3
"""
TickTick MCP Server
Provides Claude Code with direct tools to manage TickTick projects and tasks.

Requires:
  - pip install mcp httpx
  - Token file at .tokens.json (run ticktick_auth.py first)
  - Vault credentials at services/ticktick.sops.yaml
"""

import asyncio
import json
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Optional

import httpx

try:
    from mcp.server import Server
    from mcp.types import Tool, TextContent
except ImportError:
    print("[ERROR] MCP package not installed. Run: pip install mcp", file=sys.stderr)
    sys.exit(1)

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

TICKTICK_API_BASE = "https://api.ticktick.com/open/v1"
TICKTICK_OAUTH_TOKEN_URL = "https://ticktick.com/oauth/token"
SCRIPT_DIR = Path(__file__).parent
TOKENS_PATH = SCRIPT_DIR / ".tokens.json"
VAULT_SCRIPT = "D:/vault/scripts/vault.sh"
VAULT_ENTRY = "services/ticktick.sops.yaml"

# ---------------------------------------------------------------------------
# Credential & token helpers
# ---------------------------------------------------------------------------

_vault_cache: dict[str, str] = {}


def _vault_get_field(field: str) -> str:
    """Retrieve a field from the SOPS vault, caching results in memory."""
    if field in _vault_cache:
        return _vault_cache[field]
    result = subprocess.run(
        ["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field],
        capture_output=True,
        text=True,
        timeout=15,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"[ERROR] Vault lookup failed for {field}: {result.stderr.strip()}"
        )
    value = result.stdout.strip()
    _vault_cache[field] = value
    return value


def _load_tokens() -> dict[str, str]:
    """Load tokens from disk. Raises FileNotFoundError if missing."""
    if not TOKENS_PATH.exists():
        raise FileNotFoundError(
            f"[ERROR] Token file not found at {TOKENS_PATH}. "
            "Run 'python ticktick_auth.py' in the ticktick directory first "
            "to complete the OAuth flow and generate .tokens.json."
        )
    with open(TOKENS_PATH, "r", encoding="utf-8") as fh:
        return json.load(fh)


def _save_tokens(tokens: dict[str, str]) -> None:
    """Persist tokens to disk."""
    with open(TOKENS_PATH, "w", encoding="utf-8") as fh:
        json.dump(tokens, fh, indent=2)


async def _refresh_access_token(refresh_token: str) -> dict[str, str]:
    """Exchange a refresh token for a new access token."""
    client_id = _vault_get_field("credentials.client_id")
    client_secret = _vault_get_field("credentials.client_secret")

    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(
            TICKTICK_OAUTH_TOKEN_URL,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            data={
                "client_id": client_id,
                "client_secret": client_secret,
                "refresh_token": refresh_token,
                "grant_type": "refresh_token",
            },
        )
        if resp.status_code != 200:
            raise RuntimeError(
                f"[ERROR] Token refresh failed ({resp.status_code}): {resp.text}"
            )
        new_data = resp.json()

    tokens = {
        "access_token": new_data["access_token"],
        "refresh_token": new_data.get("refresh_token", refresh_token),
        "token_type": new_data.get("token_type", "bearer"),
        "obtained_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    }
    _save_tokens(tokens)
    return tokens


# ---------------------------------------------------------------------------
# HTTP helper with automatic 401 retry
# ---------------------------------------------------------------------------


async def _ticktick_request(
    method: str,
    path: str,
    *,
    json_body: Optional[dict] = None,
) -> httpx.Response:
    """
    Make an authenticated request to the TickTick API.

    On a 401 response, automatically refreshes the access token and retries
    the request exactly once.
    """
    tokens = _load_tokens()

    async with httpx.AsyncClient(timeout=30.0) as client:
        url = f"{TICKTICK_API_BASE}{path}"
        headers = {"Authorization": f"Bearer {tokens['access_token']}"}
        kwargs: dict[str, Any] = {"headers": headers}
        if json_body is not None:
            kwargs["json"] = json_body

        resp = await client.request(method, url, **kwargs)

        if resp.status_code == 401:
            # Attempt token refresh and retry once
            tokens = await _refresh_access_token(tokens["refresh_token"])
            headers = {"Authorization": f"Bearer {tokens['access_token']}"}
            kwargs["headers"] = headers
            resp = await client.request(method, url, **kwargs)

        return resp


def _format_response(data: Any) -> str:
    """Serialize a response payload to pretty JSON text."""
    if isinstance(data, (dict, list)):
        return json.dumps(data, indent=2, ensure_ascii=False)
    return str(data)


def _error_text(msg: str) -> list[TextContent]:
    return [TextContent(type="text", text=msg)]


# ---------------------------------------------------------------------------
# MCP Server
# ---------------------------------------------------------------------------

app = Server("ticktick")


@app.list_tools()
async def list_tools() -> list[Tool]:
    """Enumerate all TickTick tools."""
    return [
        # ----- Projects -----
        Tool(
            name="ticktick_list_projects",
            description="List all TickTick projects. Returns an array of projects with id, name, color, viewMode, and kind.",
            inputSchema={
                "type": "object",
                "properties": {},
            },
        ),
        Tool(
            name="ticktick_get_project",
            description="Get a TickTick project and all its tasks by project ID.",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_id": {
                        "type": "string",
                        "description": "The project ID to retrieve",
                    },
                },
                "required": ["project_id"],
            },
        ),
        Tool(
            name="ticktick_create_project",
            description="Create a new TickTick project.",
            inputSchema={
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "Project name (required)",
                    },
                    "color": {
                        "type": "string",
                        "description": "Hex color code (e.g. '#ff6347')",
                    },
                    "viewMode": {
                        "type": "string",
                        "enum": ["list", "kanban", "timeline"],
                        "description": "View mode for the project",
                    },
                    "kind": {
                        "type": "string",
                        "enum": ["TASK", "NOTE"],
                        "description": "Project kind (default TASK)",
                    },
                },
                "required": ["name"],
            },
        ),
        Tool(
            name="ticktick_update_project",
            description="Update an existing TickTick project's name, color, or viewMode.",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_id": {
                        "type": "string",
                        "description": "The project ID to update",
                    },
                    "name": {
                        "type": "string",
                        "description": "New project name",
                    },
                    "color": {
                        "type": "string",
                        "description": "New hex color code",
                    },
                    "viewMode": {
                        "type": "string",
                        "enum": ["list", "kanban", "timeline"],
                        "description": "New view mode",
                    },
                },
                "required": ["project_id"],
            },
        ),
        Tool(
            name="ticktick_delete_project",
            description="Delete a TickTick project by ID. This is irreversible.",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_id": {
                        "type": "string",
                        "description": "The project ID to delete",
                    },
                },
                "required": ["project_id"],
            },
        ),
        # ----- Tasks -----
        Tool(
            name="ticktick_create_task",
            description="Create a new task in a TickTick project.",
            inputSchema={
                "type": "object",
                "properties": {
                    "title": {
                        "type": "string",
                        "description": "Task title (required)",
                    },
                    "project_id": {
                        "type": "string",
                        "description": "Project ID to create the task in (required)",
                    },
                    "content": {
                        "type": "string",
                        "description": "Task description / notes",
                    },
                    "priority": {
                        "type": "integer",
                        "enum": [0, 1, 3, 5],
                        "description": "Priority: 0=none, 1=low, 3=medium, 5=high",
                    },
                    "due_date": {
                        "type": "string",
                        "description": "Due date in ISO 8601 format (e.g. 2026-04-01T12:00:00+0000)",
                    },
                    "tags": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "List of tag names to attach",
                    },
                },
                "required": ["title", "project_id"],
            },
        ),
        Tool(
            name="ticktick_update_task",
            description="Update an existing task's title, content, priority, due date, or tags.",
            inputSchema={
                "type": "object",
                "properties": {
                    "task_id": {
                        "type": "string",
                        "description": "The task ID to update",
                    },
                    "project_id": {
                        "type": "string",
                        "description": "The project ID the task belongs to",
                    },
                    "title": {
                        "type": "string",
                        "description": "New task title",
                    },
                    "content": {
                        "type": "string",
                        "description": "New task description / notes",
                    },
                    "priority": {
                        "type": "integer",
                        "enum": [0, 1, 3, 5],
                        "description": "New priority: 0=none, 1=low, 3=medium, 5=high",
                    },
                    "due_date": {
                        "type": "string",
                        "description": "New due date in ISO 8601 format",
                    },
                    "tags": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Replacement list of tag names",
                    },
                },
                "required": ["task_id", "project_id"],
            },
        ),
        Tool(
            name="ticktick_complete_task",
            description="Mark a TickTick task as completed.",
            inputSchema={
                "type": "object",
                "properties": {
                    "task_id": {
                        "type": "string",
                        "description": "The task ID to complete",
                    },
                    "project_id": {
                        "type": "string",
                        "description": "The project ID the task belongs to",
                    },
                },
                "required": ["task_id", "project_id"],
            },
        ),
        Tool(
            name="ticktick_delete_task",
            description="Delete a TickTick task. This is irreversible.",
            inputSchema={
                "type": "object",
                "properties": {
                    "task_id": {
                        "type": "string",
                        "description": "The task ID to delete",
                    },
                    "project_id": {
                        "type": "string",
                        "description": "The project ID the task belongs to",
                    },
                },
                "required": ["task_id", "project_id"],
            },
        ),
    ]


# ---------------------------------------------------------------------------
# Tool dispatch
# ---------------------------------------------------------------------------


@app.call_tool()
async def call_tool(name: str, arguments: Any) -> list[TextContent]:
    """Route tool calls to the appropriate handler."""
    try:
        if name == "ticktick_list_projects":
            return await _handle_list_projects()
        elif name == "ticktick_get_project":
            return await _handle_get_project(arguments)
        elif name == "ticktick_create_project":
            return await _handle_create_project(arguments)
        elif name == "ticktick_update_project":
            return await _handle_update_project(arguments)
        elif name == "ticktick_delete_project":
            return await _handle_delete_project(arguments)
        elif name == "ticktick_create_task":
            return await _handle_create_task(arguments)
        elif name == "ticktick_update_task":
            return await _handle_update_task(arguments)
        elif name == "ticktick_complete_task":
            return await _handle_complete_task(arguments)
        elif name == "ticktick_delete_task":
            return await _handle_delete_task(arguments)
        else:
            return _error_text(f"[ERROR] Unknown tool: {name}")
    except FileNotFoundError as exc:
        return _error_text(str(exc))
    except RuntimeError as exc:
        return _error_text(str(exc))
    except Exception as exc:
        return _error_text(f"[ERROR] Unexpected failure in {name}: {exc}")


# ---------------------------------------------------------------------------
# Handler implementations
# ---------------------------------------------------------------------------


async def _handle_list_projects() -> list[TextContent]:
    resp = await _ticktick_request("GET", "/project")
    if resp.status_code != 200:
        return _error_text(
            f"[ERROR] Failed to list projects ({resp.status_code}): {resp.text}"
        )
    projects = resp.json()
    return [TextContent(type="text", text=f"[OK] {len(projects)} projects found\n\n{_format_response(projects)}")]


async def _handle_get_project(args: dict) -> list[TextContent]:
    project_id = args["project_id"]
    resp = await _ticktick_request("GET", f"/project/{project_id}/data")
    if resp.status_code != 200:
        return _error_text(
            f"[ERROR] Failed to get project {project_id} ({resp.status_code}): {resp.text}"
        )
    data = resp.json()
    task_count = len(data.get("tasks", []))
    return [TextContent(type="text", text=f"[OK] Project retrieved ({task_count} tasks)\n\n{_format_response(data)}")]


async def _handle_create_project(args: dict) -> list[TextContent]:
    body: dict[str, Any] = {"name": args["name"]}
    for key in ("color", "viewMode", "kind"):
        if key in args:
            body[key] = args[key]

    resp = await _ticktick_request("POST", "/project", json_body=body)
    if resp.status_code not in (200, 201):
        return _error_text(
            f"[ERROR] Failed to create project ({resp.status_code}): {resp.text}"
        )
    project = resp.json()
    return [TextContent(type="text", text=f"[OK] Project created\n\n{_format_response(project)}")]


async def _handle_update_project(args: dict) -> list[TextContent]:
    project_id = args["project_id"]
    body: dict[str, Any] = {}
    for key in ("name", "color", "viewMode"):
        if key in args:
            body[key] = args[key]

    if not body:
        return _error_text("[WARNING] No update fields provided. Supply at least one of: name, color, viewMode.")

    # TickTick uses POST for project updates in some API versions; fall back to PUT.
    resp = await _ticktick_request("POST", f"/project/{project_id}", json_body=body)
    if resp.status_code in (404, 405):
        resp = await _ticktick_request("PUT", f"/project/{project_id}", json_body=body)

    if resp.status_code not in (200, 201):
        return _error_text(
            f"[ERROR] Failed to update project {project_id} ({resp.status_code}): {resp.text}"
        )
    project = resp.json()
    return [TextContent(type="text", text=f"[OK] Project updated\n\n{_format_response(project)}")]


async def _handle_delete_project(args: dict) -> list[TextContent]:
    project_id = args["project_id"]
    resp = await _ticktick_request("DELETE", f"/project/{project_id}")
    if resp.status_code not in (200, 204):
        return _error_text(
            f"[ERROR] Failed to delete project {project_id} ({resp.status_code}): {resp.text}"
        )
    return [TextContent(type="text", text=f"[OK] Project {project_id} deleted successfully.")]


async def _handle_create_task(args: dict) -> list[TextContent]:
    body: dict[str, Any] = {
        "title": args["title"],
        "projectId": args["project_id"],
    }
    if "content" in args:
        body["content"] = args["content"]
    if "priority" in args:
        body["priority"] = args["priority"]
    if "due_date" in args:
        body["dueDate"] = args["due_date"]
    if "tags" in args:
        body["tags"] = args["tags"]

    resp = await _ticktick_request("POST", "/task", json_body=body)
    if resp.status_code not in (200, 201):
        return _error_text(
            f"[ERROR] Failed to create task ({resp.status_code}): {resp.text}"
        )
    task = resp.json()
    return [TextContent(type="text", text=f"[OK] Task created\n\n{_format_response(task)}")]


async def _handle_update_task(args: dict) -> list[TextContent]:
    task_id = args["task_id"]
    project_id = args["project_id"]

    body: dict[str, Any] = {
        "taskId": task_id,
        "projectId": project_id,
    }
    if "title" in args:
        body["title"] = args["title"]
    if "content" in args:
        body["content"] = args["content"]
    if "priority" in args:
        body["priority"] = args["priority"]
    if "due_date" in args:
        body["dueDate"] = args["due_date"]
    if "tags" in args:
        body["tags"] = args["tags"]

    resp = await _ticktick_request("POST", f"/task/{task_id}", json_body=body)
    if resp.status_code not in (200, 201):
        return _error_text(
            f"[ERROR] Failed to update task {task_id} ({resp.status_code}): {resp.text}"
        )
    task = resp.json()
    return [TextContent(type="text", text=f"[OK] Task updated\n\n{_format_response(task)}")]


async def _handle_complete_task(args: dict) -> list[TextContent]:
    task_id = args["task_id"]
    project_id = args["project_id"]
    resp = await _ticktick_request(
        "POST", f"/project/{project_id}/task/{task_id}/complete"
    )
    if resp.status_code not in (200, 204):
        return _error_text(
            f"[ERROR] Failed to complete task {task_id} ({resp.status_code}): {resp.text}"
        )
    return [TextContent(type="text", text=f"[OK] Task {task_id} marked as completed.")]


async def _handle_delete_task(args: dict) -> list[TextContent]:
    task_id = args["task_id"]
    project_id = args["project_id"]
    resp = await _ticktick_request(
        "DELETE", f"/project/{project_id}/task/{task_id}"
    )
    if resp.status_code not in (200, 204):
        return _error_text(
            f"[ERROR] Failed to delete task {task_id} ({resp.status_code}): {resp.text}"
        )
    return [TextContent(type="text", text=f"[OK] Task {task_id} deleted successfully.")]


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


async def main() -> None:
    """Run the TickTick MCP server over stdio transport."""
    try:
        from mcp.server.stdio import stdio_server

        async with stdio_server() as (read_stream, write_stream):
            await app.run(
                read_stream,
                write_stream,
                app.create_initialization_options(),
            )
    except Exception as exc:
        print(f"[ERROR] MCP server failed: {exc}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    asyncio.run(main())
