#!/usr/bin/env python3
"""Keep an Investigation Desk wiki in a local JSON database.

The file uses the same investigation-desk-wiki version 1 format as the site
export. No network calls and no credentials.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path

FORMAT = "investigation-desk-wiki"
VERSION = 1
MAX_BODY = 80000
MAX_REVISIONS = 50


def wiki_slug(title: str) -> str:
    cleaned = title.strip()
    for char in "/?#&\\":
        cleaned = cleaned.replace(char, "")
    cleaned = re.sub(r"\s+", "_", cleaned)
    return cleaned[:120]


def stamp_now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"


MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".webm", ".mp3", ".wav", ".ogg", ".m4a"}


def media_file_name(value: str) -> str:
    name = value.strip()
    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}", name):
        return ""
    suffix = Path(name).suffix.lower()
    return name if suffix in MEDIA_EXTENSIONS else ""


def media_dir(database: Path) -> Path:
    return database.parent / "media"


def default_database() -> Path:
    override = os.environ.get("INVESTIGATION_DESK_DB", "").strip()
    return Path(override) if override else Path(".investigation-desk") / "wiki.json"


def links_in(body: str) -> list[str]:
    found: list[str] = []
    for match in re.finditer(r"\[\[([^\]|\n]+?)(?:\|[^\]\n]+)?\]\]", body):
        target = match.group(1).strip()
        if target.lower().startswith("file:"):
            continue
        slug = wiki_slug(target)
        if slug and not slug.startswith("Special:") and slug not in found:
            found.append(slug)
    return found


def sql_quote(value: str) -> str:
    return "'" + value.replace("\x00", "").replace("'", "''") + "'"


def empty_database() -> dict:
    # Early on purpose: importing this starter into a wiki that already has a Main Page keeps that page.
    started = "2020-01-01T00:00:00.000Z"
    body = (
        "This is a local Investigation Desk wiki. Add one page per investigation. "
        "Link another page with [[Page title]]. Math uses $...$ or $$...$$.\n"
    )
    return {
        "format": FORMAT,
        "version": VERSION,
        "exported_at": stamp_now(),
        "database": "local",
        "pages": [
            {
                "slug": "Main_Page",
                "title": "Main Page",
                "body_markdown": body,
                "updated_at": started,
                "updated_by": "local",
                "revisions": [revision("Main Page", body, "Started the local wiki", started)],
                "links": [],
            }
        ],
    }


def revision(title: str, body: str, note: str, when: str) -> dict:
    return {
        "id": f"local-{uuid.uuid4()}",
        "title": title,
        "body_markdown": body,
        "editor": "local",
        "note": note[:200],
        "created_at": when,
    }


def load(path: Path) -> dict:
    if not path.exists():
        raise SystemExit(f"No local wiki at {path}. Run init first.")
    data = json.loads(path.read_text(encoding="utf-8"))
    if data.get("format") != FORMAT or data.get("version") != VERSION or not isinstance(data.get("pages"), list):
        raise SystemExit("That file is not an Investigation Desk wiki database.")
    return data


def save(path: Path, data: dict) -> None:
    data["exported_at"] = stamp_now()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def find_page(data: dict, slug: str) -> dict | None:
    for page in data["pages"]:
        if page.get("slug") == slug:
            return page
    return None


def command_init(path: Path) -> None:
    if path.exists():
        print(f"kept {path}")
        return
    save(path, empty_database())
    print(f"created {path}")


def command_put(path: Path, title: str, body: str, summary: str) -> None:
    title = title.strip()
    slug = wiki_slug(title)
    if not slug or slug.startswith("Special:") or not title or len(title) > 180 or len(body) > MAX_BODY:
        raise SystemExit("Title or text is missing or too large.")
    data = load(path)
    when = stamp_now()
    page = find_page(data, slug)
    record = revision(title, body, summary or "Local edit", when)
    if page is None:
        data["pages"].append(
            {
                "slug": slug,
                "title": title,
                "body_markdown": body,
                "updated_at": when,
                "updated_by": "local",
                "revisions": [record],
                "links": links_in(body),
            }
        )
    else:
        page.update(
            {
                "title": title,
                "body_markdown": body,
                "updated_at": when,
                "updated_by": "local",
                "links": links_in(body),
            }
        )
        revisions = list(page.get("revisions") or [])
        revisions.append(record)
        page["revisions"] = revisions[-MAX_REVISIONS:]
    save(path, data)
    print(slug)


def command_get(path: Path, slug: str) -> None:
    page = find_page(load(path), wiki_slug(slug))
    if page is None:
        raise SystemExit(f"No page {slug}")
    sys.stdout.write(page.get("body_markdown") or "")
    if not str(page.get("body_markdown") or "").endswith("\n"):
        sys.stdout.write("\n")


def command_list(path: Path) -> None:
    pages = sorted(load(path)["pages"], key=lambda page: str(page.get("title") or "").lower())
    for page in pages:
        print(f"{page.get('slug')}\t{page.get('title')}")


def command_export_sql(path: Path, out: Path | None) -> None:
    data = load(path)
    lines = [
        f"-- {FORMAT} version {VERSION}",
        "-- Load into another Investigation Desk D1 or SQLite database.",
        "-- A page already stored with a newer updated_at is left in place.",
        "BEGIN;",
    ]
    for page in data["pages"]:
        lines.append(
            "INSERT INTO wiki_pages (slug, title, body_markdown, updated_at, updated_by) VALUES ("
            + ", ".join(
                sql_quote(str(page.get(key) or ""))
                for key in ("slug", "title", "body_markdown", "updated_at", "updated_by")
            )
            + ") ON CONFLICT(slug) DO UPDATE SET title = excluded.title, body_markdown = excluded.body_markdown, "
            "updated_at = excluded.updated_at, updated_by = excluded.updated_by "
            "WHERE excluded.updated_at > wiki_pages.updated_at;"
        )
        for item in page.get("revisions") or []:
            lines.append(
                "INSERT OR IGNORE INTO wiki_revisions (id, slug, title, body_markdown, editor, note, created_at) VALUES ("
                + ", ".join(
                    [
                        sql_quote(str(item.get("id") or "")),
                        sql_quote(str(page.get("slug") or "")),
                        sql_quote(str(item.get("title") or "")),
                        sql_quote(str(item.get("body_markdown") or "")),
                        sql_quote(str(item.get("editor") or "")),
                        sql_quote(str(item.get("note") or "")),
                        sql_quote(str(item.get("created_at") or "")),
                    ]
                )
                + ");"
            )
        for target in page.get("links") or []:
            if target != page.get("slug"):
                lines.append(
                    "INSERT OR IGNORE INTO wiki_links (from_slug, to_slug) VALUES ("
                    + sql_quote(str(page.get("slug") or ""))
                    + ", "
                    + sql_quote(str(target))
                    + ");"
                )
    lines.append("COMMIT;")
    text = "\n".join(lines) + "\n"
    if out is None:
        sys.stdout.write(text)
    else:
        out.write_text(text, encoding="utf-8")
        print(f"wrote {out}")


def command_import(path: Path, source: Path) -> None:
    incoming = json.loads(source.read_text(encoding="utf-8"))
    if incoming.get("format") != FORMAT or incoming.get("version") != VERSION:
        raise SystemExit("That file is not an Investigation Desk wiki database.")
    if not path.exists():
        save(path, empty_database())
    data = load(path)
    stored = 0
    kept = 0
    for page in incoming.get("pages") or []:
        slug = wiki_slug(str(page.get("slug") or ""))
        if not slug or slug.startswith("Special:") or slug != page.get("slug"):
            raise SystemExit("A page has a bad slug.")
        current = find_page(data, slug)
        revisions = [item for item in (page.get("revisions") or []) if isinstance(item, dict)][-MAX_REVISIONS:]
        if current is None or str(page.get("updated_at") or "") > str(current.get("updated_at") or ""):
            record = {
                "slug": slug,
                "title": str(page.get("title") or slug),
                "body_markdown": str(page.get("body_markdown") or ""),
                "updated_at": str(page.get("updated_at") or stamp_now()),
                "updated_by": str(page.get("updated_by") or "local")[:120],
                "revisions": revisions,
                "links": links_in(str(page.get("body_markdown") or "")),
            }
            if current is None:
                data["pages"].append(record)
            else:
                current.clear()
                current.update(record)
            stored += 1
        else:
            known = {item.get("id") for item in current.get("revisions") or []}
            for item in revisions:
                if item.get("id") not in known:
                    current.setdefault("revisions", []).append(item)
            current["revisions"] = list(current.get("revisions") or [])[-MAX_REVISIONS:]
            kept += 1
    save(path, data)
    print(f"stored {stored} kept {kept}")


def command_add_media(path: Path, source: Path, page_title: str) -> None:
    name = media_file_name(source.name)
    if not name:
        raise SystemExit("Use a file name of letters, numbers, dots, or dashes, and a png, jpg, gif, webp, mp4, webm, mp3, wav, ogg, or m4a extension.")
    if not source.is_file():
        raise SystemExit(f"No file {source}")
    if source.stat().st_size > 25_000_000:
        raise SystemExit("Media files must be 25 MB or smaller.")
    if not path.exists():
        command_init(path)
    destination = media_dir(path) / name
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_bytes(source.read_bytes())
    embed = f"[[File:{name}]]"
    data = load(path)
    page = find_page(data, wiki_slug(page_title))
    body = page.get("body_markdown") if page else f"Media for {page_title}.\n\n"
    if not isinstance(body, str):
        body = ""
    if embed not in body:
        body = body.rstrip() + f"\n\n{embed}\n"
        command_put(path, page_title, body, f"Added {name}")
    print(f"/media/{name}")


def read_body(args: argparse.Namespace) -> str:
    if args.body_file:
        return Path(args.body_file).read_text(encoding="utf-8")
    if args.body is not None:
        return args.body
    raise SystemExit("Pass --body or --body-file.")


def main() -> None:
    parser = argparse.ArgumentParser(description="Keep a local Investigation Desk wiki database.")
    parser.add_argument("--db", type=Path, default=None, help="JSON database path. Defaults to INVESTIGATION_DESK_DB or .investigation-desk/wiki.json")
    commands = parser.add_subparsers(dest="command", required=True)
    commands.add_parser("init")
    put = commands.add_parser("put")
    put.add_argument("--title", required=True)
    put.add_argument("--body")
    put.add_argument("--body-file")
    put.add_argument("--summary", default="Local edit")
    get = commands.add_parser("get")
    get.add_argument("slug")
    commands.add_parser("list")
    export = commands.add_parser("export-sql")
    export.add_argument("--out", type=Path)
    imported = commands.add_parser("import")
    imported.add_argument("source", type=Path)
    media = commands.add_parser("add-media")
    media.add_argument("--file", type=Path, required=True)
    media.add_argument("--page", default="Media")
    args = parser.parse_args()
    path = args.db or default_database()
    if args.command == "init":
        command_init(path)
    elif args.command == "put":
        command_put(path, args.title, read_body(args), args.summary)
    elif args.command == "get":
        command_get(path, args.slug)
    elif args.command == "list":
        command_list(path)
    elif args.command == "export-sql":
        command_export_sql(path, args.out)
    elif args.command == "import":
        command_import(path, args.source)
    elif args.command == "add-media":
        command_add_media(path, args.file, args.page)


if __name__ == "__main__":
    main()
