#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
==============================================================================
MCP server: TRILIUM  (zápis poznámek do Trilium Notes přes ETAPI)
Verze:  1.0
Datum:  2026-06-09
Autor:  vladimir.buzalka

Účel:
  Umožnit libovolnému MCP klientovi (Claude Code v jiné session, Claude
  Chat, Cowork, …) ČÍST a hlavně PSÁT poznámky do Trilia běžícího na
  https://trilium.buzalka.cz. Slouží k rychlému předávání poznámek/souborů
  do prostředí, kam se jinak nedá kopírovat (např. JNJ remote PC).

Komunikace:
  Trilium ETAPI (REST), autentizace hlavičkou Authorization: <token>.
  Používá jen Python stdlib (urllib) — žádné externí HTTP knihovny.

Konvence řazení:
  Výchozí rodič je složka "Claude" (root > Claude), která má nastaveno
  #sorted=dateCreated + #sortDirection=desc => nové poznámky jdou NAHORU
  automaticky, není třeba řešit pozice.

Bezpečnost zápisu (dle zvyklostí ostatních zdejších MCP serverů):
  - create_note / append_note  ... bez tření (hlavní účel serveru)
  - set_note_content (přepis)  ... vyžaduje confirmed=True
  - delete_note                ... vyžaduje confirmed=True

Spuštění:
    python mcp_trilium_v1.0.py            (stdio MCP server)
    python mcp_trilium_v1.0.py --selftest (rychlý test create/read/append/delete)

Registrace v .mcp.json jako "trilium" (viz doprovodné .md).
==============================================================================
"""

from __future__ import annotations

import os
import sys
import json
import html
import hashlib
import mimetypes
import urllib.parse
import urllib.request
import urllib.error
from typing import Optional

from mcp.server.fastmcp import FastMCP

# --- konfigurace ------------------------------------------------------------
BASE_URL = os.environ.get("TRILIUM_URL", "https://trilium.buzalka.cz").rstrip("/")
ETAPI = BASE_URL + "/etapi"
# Token lze přepsat proměnnou prostředí TRILIUM_ETAPI_TOKEN; jinak default níže.
TOKEN = os.environ.get(
    "TRILIUM_ETAPI_TOKEN",
    "WoPH9O8hn2y6_r6pQSjpOVSmuL0os2hIQsLBHDOawebOx8l+MUc8v+GE=",
)
# Výchozí složka, kam se zapisuje (root > Claude), newest-first.
CLAUDE_NOTE_ID = os.environ.get("TRILIUM_DEFAULT_PARENT", "NeoXOIw0uBK2")

HTTP_TIMEOUT = 20


def log(msg: str) -> None:
    print(msg, file=sys.stderr, flush=True)


# --- nízkoúrovňové ETAPI volání ---------------------------------------------
def _req(method: str, path: str, *, json_body=None, raw_body: Optional[bytes] = None,
         raw_ctype: Optional[str] = None, expect_bytes: bool = False):
    """Jedno ETAPI volání. Vrací dict/list/str (JSON nebo text), nebo bytes
    (expect_bytes=True). Vyhazuje RuntimeError s tělem chyby při HTTP >= 400."""
    body = None
    ctype = None
    if json_body is not None:
        body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
        ctype = "application/json; charset=utf-8"
    elif raw_body is not None:
        body = raw_body
        ctype = raw_ctype or "application/octet-stream"

    req = urllib.request.Request(ETAPI + path, data=body, method=method)
    req.add_header("Authorization", TOKEN)
    if ctype:
        req.add_header("Content-Type", ctype)

    try:
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
            data = resp.read()
            if expect_bytes:
                return data
            text = data.decode("utf-8", errors="replace")
            if text.strip().startswith(("{", "[")):
                return json.loads(text)
            return text
    except urllib.error.HTTPError as e:
        detail = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"ETAPI {method} {path} -> HTTP {e.code}: {detail}") from None
    except urllib.error.URLError as e:
        raise RuntimeError(f"ETAPI {method} {path} -> spojení selhalo: {e.reason}") from None


def _text_to_html(text: str, is_html: bool) -> str:
    """Plain text -> bezpečné HTML (prázdné řádky = odstavce, \\n = <br>).
    Pokud is_html=True, obsah se bere tak, jak je."""
    if is_html:
        return text
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    parts = []
    for para in text.split("\n\n"):
        if para.strip() == "":
            continue
        parts.append("<p>" + html.escape(para).replace("\n", "<br>") + "</p>")
    return "".join(parts) or "<p></p>"


def _put_text_content(note_id: str, html_content: str) -> None:
    """Uloží HTML obsah textové poznámky (UTF-8, ověřený způsob: text/plain;charset)."""
    _req("PUT", f"/notes/{note_id}/content",
         raw_body=html_content.encode("utf-8"),
         raw_ctype="text/plain; charset=utf-8")


def _note_summary(note: dict) -> dict:
    """Zhuštěné info o poznámce do tool response."""
    return {
        "noteId": note.get("noteId"),
        "title": note.get("title"),
        "type": note.get("type"),
        "mime": note.get("mime"),
        "parentNoteIds": note.get("parentNoteIds"),
        "childNoteIds": note.get("childNoteIds"),
        "dateCreated": note.get("dateCreated"),
        "dateModified": note.get("dateModified"),
        "url": f"{BASE_URL}/#root/{note.get('noteId')}",
    }


# --- startup check ----------------------------------------------------------
try:
    _info = _req("GET", "/app-info")
    log(f"Trilium OK ({BASE_URL}) — appVersion {_info.get('appVersion')}")
except Exception as e:  # noqa: BLE001
    log(f"Trilium ETAPI nedostupné: {e}")
    sys.exit(1)


# --- MCP --------------------------------------------------------------------
mcp = FastMCP("trilium")


@mcp.tool()
def ping() -> dict:
    """Health check Trilium ETAPI. Vrátí verzi aplikace, DB a default složku.
    Zavolej jako první pro ověření, že je server dostupný a token platí."""
    try:
        info = _req("GET", "/app-info")
        return {
            "status": "ok",
            "base_url": BASE_URL,
            "appVersion": info.get("appVersion"),
            "dbVersion": info.get("dbVersion"),
            "default_parent": CLAUDE_NOTE_ID,
            "default_parent_url": f"{BASE_URL}/#root/{CLAUDE_NOTE_ID}",
        }
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def create_note(
    title: str,
    content: str = "",
    parent_note_id: Optional[str] = None,
    is_html: bool = False,
) -> dict:
    """Vytvoří NOVOU textovou poznámku. Hlavní nástroj pro předávání poznámek.

    title:          název poznámky (zobrazí se ve stromu)
    content:        text poznámky; prostý text se převede na HTML (prázdný
                    řádek = nový odstavec). Pokud už posíláš HTML, nastav is_html=True.
    parent_note_id: kam ji zařadit. Default = složka "Claude" (root > Claude),
                    která řadí potomky nejnovější-nahoře automaticky.
    is_html:        True pokud content už JE HTML.

    Vrací noteId a URL nové poznámky.
    """
    try:
        parent = parent_note_id or CLAUDE_NOTE_ID
        html_content = _text_to_html(content, is_html)
        res = _req("POST", "/create-note", json_body={
            "parentNoteId": parent,
            "title": title,
            "type": "text",
            "content": html_content,
        })
        note = res["note"]
        log(f"create_note: {note['noteId']} '{title}' pod {parent}")
        return {"status": "ok", **_note_summary(note)}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def append_note(note_id: str, text: str, is_html: bool = False) -> dict:
    """Připíše text na KONEC existující textové poznámky (nedestruktivní).

    note_id: ID poznámky, ke které připisujeme
    text:    co připsat; prostý text -> HTML (is_html=True pokud posíláš HTML)
    """
    try:
        meta = _req("GET", f"/notes/{note_id}")
        if meta.get("type") != "text":
            return {"status": "error",
                    "error": f"Poznámka {note_id} není typu 'text' (je '{meta.get('type')}')."}
        current = _req("GET", f"/notes/{note_id}/content")
        if not isinstance(current, str):
            current = json.dumps(current, ensure_ascii=False)
        addition = _text_to_html(text, is_html)
        _put_text_content(note_id, (current or "") + addition)
        log(f"append_note: {note_id} (+{len(text)} znaků)")
        return {"status": "ok", "noteId": note_id,
                "url": f"{BASE_URL}/#root/{note_id}"}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def set_note_content(note_id: str, content: str, is_html: bool = False,
                     confirmed: bool = False) -> dict:
    """PŘEPÍŠE celý obsah textové poznámky (DESTRUKTIVNÍ — smaže stávající text).

    Vyžaduje confirmed=True. Při confirmed=False jen vrátí náhled: délku
    stávajícího obsahu a co by se zapsalo — to ukaž uživateli a teprve po
    schválení zavolej znovu s confirmed=True.
    """
    try:
        meta = _req("GET", f"/notes/{note_id}")
        if meta.get("type") != "text":
            return {"status": "error",
                    "error": f"Poznámka {note_id} není typu 'text' (je '{meta.get('type')}')."}
        new_html = _text_to_html(content, is_html)
        if not confirmed:
            current = _req("GET", f"/notes/{note_id}/content")
            cur_len = len(current) if isinstance(current, str) else 0
            return {
                "status": "preview",
                "noteId": note_id,
                "title": meta.get("title"),
                "current_content_length": cur_len,
                "new_content_preview": new_html[:500],
                "note": "Přepis smaže stávající obsah. Ukaž uživateli a zavolej znovu s confirmed=True.",
            }
        _put_text_content(note_id, new_html)
        log(f"set_note_content: {note_id} přepsáno")
        return {"status": "ok", "noteId": note_id,
                "url": f"{BASE_URL}/#root/{note_id}"}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def create_folder(title: str, parent_note_id: str = "root",
                  newest_first: bool = True, place_on_top: bool = False) -> dict:
    """Vytvoří podsložku (poznámku, pod kterou se vnořují další).

    title:          název složky
    parent_note_id: rodič (default "root")
    newest_first:   nastaví #sorted=dateCreated + #sortDirection=desc, takže
                    potomci se řadí nejnovější-nahoře (jako složka Claude)
    place_on_top:   posune složku na úplný začátek u svého rodiče (pozice 0)
    """
    try:
        res = _req("POST", "/create-note", json_body={
            "parentNoteId": parent_note_id,
            "title": title,
            "type": "text",
            "content": "",
        })
        note = res["note"]
        nid = note["noteId"]
        if newest_first:
            _req("POST", "/attributes", json_body={
                "noteId": nid, "type": "label", "name": "sorted",
                "value": "dateCreated", "isInheritable": False})
            _req("POST", "/attributes", json_body={
                "noteId": nid, "type": "label", "name": "sortDirection",
                "value": "desc", "isInheritable": False})
        if place_on_top:
            branch_id = res["branch"]["branchId"]
            _req("PATCH", f"/branches/{branch_id}", json_body={"notePosition": 0})
        log(f"create_folder: {nid} '{title}' pod {parent_note_id}")
        return {"status": "ok", "newest_first": newest_first,
                "place_on_top": place_on_top, **_note_summary(note)}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def upload_file(file_path: str, parent_note_id: Optional[str] = None,
                title: Optional[str] = None) -> dict:
    """Nahraje LOKÁLNÍ soubor (z disku stroje, kde běží tento MCP) jako
    samostatnou poznámku typu 'file'. Po nahrání ověří integritu přes SHA-256.

    file_path:      absolutní cesta k souboru na tomto stroji
    parent_note_id: kam (default složka Claude)
    title:          název poznámky (default = jméno souboru)
    """
    try:
        if not os.path.isfile(file_path):
            return {"status": "error", "error": f"Soubor neexistuje: {file_path}"}
        parent = parent_note_id or CLAUDE_NOTE_ID
        fname = os.path.basename(file_path)
        with open(file_path, "rb") as fh:
            blob = fh.read()
        mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
        sha_local = hashlib.sha256(blob).hexdigest()

        res = _req("POST", "/create-note", json_body={
            "parentNoteId": parent, "title": title or fname,
            "type": "file", "mime": mime, "content": ""})
        nid = res["note"]["noteId"]
        _req("PUT", f"/notes/{nid}/content", raw_body=blob,
             raw_ctype="application/octet-stream")
        _req("POST", "/attributes", json_body={
            "noteId": nid, "type": "label", "name": "originalFileName",
            "value": fname, "isInheritable": False})

        back = _req("GET", f"/notes/{nid}/content", expect_bytes=True)
        sha_remote = hashlib.sha256(back).hexdigest()
        log(f"upload_file: {nid} '{fname}' {len(blob)}B match={sha_local == sha_remote}")
        return {
            "status": "ok",
            "noteId": nid,
            "title": title or fname,
            "mime": mime,
            "size_bytes": len(blob),
            "sha256": sha_local,
            "integrity_ok": sha_local == sha_remote,
            "url": f"{BASE_URL}/#root/{nid}",
        }
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def read_note(note_id: str) -> dict:
    """Přečte obsah poznámky (text/HTML) + základní metadata."""
    try:
        meta = _req("GET", f"/notes/{note_id}")
        result = {"status": "ok", **_note_summary(meta)}
        if meta.get("type") in ("text", "code", "mermaid"):
            content = _req("GET", f"/notes/{note_id}/content")
            result["content"] = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
        else:
            result["content"] = f"<binární obsah typu {meta.get('type')}/{meta.get('mime')}; použij stažení v UI>"
        return result
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def list_children(note_id: Optional[str] = None) -> dict:
    """Vypíše přímé potomky poznámky (default = složka Claude) v pořadí stromu."""
    try:
        nid = note_id or CLAUDE_NOTE_ID
        meta = _req("GET", f"/notes/{nid}")
        children = []
        for child_branch in meta.get("childBranchIds", []):
            br = _req("GET", f"/branches/{child_branch}")
            ch = _req("GET", f"/notes/{br['noteId']}")
            children.append({
                "noteId": ch.get("noteId"),
                "title": ch.get("title"),
                "type": ch.get("type"),
                "notePosition": br.get("notePosition"),
                "dateCreated": ch.get("dateCreated"),
            })
        return {"status": "ok", "parent": nid, "parent_title": meta.get("title"),
                "count": len(children), "children": children}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def search_notes(query: str, limit: int = 20, ancestor_note_id: Optional[str] = None) -> dict:
    """Fulltextové / atributové vyhledávání poznámek (Trilium search syntax).

    query:            např. 'protokol', '#book', 'note.title *=* report'
    limit:            max počet výsledků (default 20)
    ancestor_note_id: omez hledání na podstrom (např. složka Claude)
    """
    try:
        params = {"search": query, "limit": str(max(1, min(limit, 200)))}
        if ancestor_note_id:
            params["ancestorNoteId"] = ancestor_note_id
        qs = urllib.parse.urlencode(params)
        res = _req("GET", f"/notes?{qs}")
        results = res.get("results", []) if isinstance(res, dict) else []
        out = [{
            "noteId": n.get("noteId"),
            "title": n.get("title"),
            "type": n.get("type"),
            "dateModified": n.get("dateModified"),
            "url": f"{BASE_URL}/#root/{n.get('noteId')}",
        } for n in results]
        return {"status": "ok", "query": query, "count": len(out), "results": out}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


@mcp.tool()
def delete_note(note_id: str, confirmed: bool = False) -> dict:
    """SMAŽE poznámku (a její potomky) — DESTRUKTIVNÍ.

    Vyžaduje confirmed=True. Při confirmed=False vrátí náhled (název + počet
    potomků), který ukaž uživateli; teprve po schválení zavolej s confirmed=True.
    """
    try:
        meta = _req("GET", f"/notes/{note_id}")
        if not confirmed:
            return {
                "status": "preview",
                "noteId": note_id,
                "title": meta.get("title"),
                "type": meta.get("type"),
                "child_count": len(meta.get("childNoteIds", [])),
                "note": "Smazání je nevratné a zahrne i potomky. Ukaž uživateli a zavolej znovu s confirmed=True.",
            }
        _req("DELETE", f"/notes/{note_id}")
        log(f"delete_note: {note_id} '{meta.get('title')}' smazáno")
        return {"status": "ok", "deleted": note_id, "title": meta.get("title")}
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}


# --- selftest (mimo MCP) ----------------------------------------------------
def selftest() -> None:
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except Exception:
        pass
    print("== Trilium MCP selftest ==")
    print("ping:", json.dumps(ping(), ensure_ascii=False))
    c = create_note("MCP selftest – ěščřž", "První řádek.\n\nDruhý odstavec — diakritika.")
    print("create:", json.dumps(c, ensure_ascii=False))
    nid = c.get("noteId")
    if nid:
        print("append:", json.dumps(append_note(nid, "Připsaný řádek."), ensure_ascii=False))
        r = read_note(nid)
        print("read.content:", r.get("content"))
        print("delete:", json.dumps(delete_note(nid, confirmed=True), ensure_ascii=False))


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        selftest()
    else:
        log("MCP trilium server started (FastMCP)")
        mcp.run()
