"""Project wiki: durable cross-experiment knowledge.

Storage:
    abreka/wiki/pages/<slug>.md     # TOML frontmatter + markdown body
    abreka/wiki_index.json          # auto-maintained, one entry per page
"""

from __future__ import annotations

import io
import re
import tomllib
from datetime import datetime, timezone
from pathlib import Path

import tomli_w

from abreka.errors import AbrekaError
from abreka.models import WikiPage

try:
    import fcntl
except ImportError:  # pragma: no cover - Windows fallback
    fcntl = None


_FRONTMATTER_DELIM = "+++"
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SLUG_NORMALIZE_RE = re.compile(r"[^a-z0-9]+")


class WikiError(AbrekaError):
    """Base error for wiki operations."""


class WikiPageNotFoundError(WikiError):
    """Raised when a referenced wiki page does not exist."""


class WikiPageExistsError(WikiError):
    """Raised when creating a page whose slug already exists."""


class InvalidSlugError(WikiError):
    """Raised when a slug cannot be normalized to a valid form."""


def normalize_slug(raw: str) -> str:
    """Normalize a slug to lowercase kebab-case.

    Allows letters, digits, and hyphens; collapses runs of other characters
    (including whitespace and underscores) to a single hyphen; strips leading
    and trailing hyphens; lowercases. Raises ``InvalidSlugError`` on empty.
    """
    if not raw:
        raise InvalidSlugError("Empty slug.")
    lowered = raw.strip().lower()
    cleaned = _SLUG_NORMALIZE_RE.sub("-", lowered).strip("-")
    if not cleaned:
        raise InvalidSlugError(f"Slug {raw!r} normalizes to empty.")
    if not _SLUG_RE.match(cleaned):
        raise InvalidSlugError(f"Invalid slug after normalization: {cleaned!r}")
    return cleaned


def wiki_dir(abreka_dir: Path) -> Path:
    return abreka_dir / "wiki"


def wiki_pages_dir(abreka_dir: Path) -> Path:
    return wiki_dir(abreka_dir) / "pages"


def wiki_index_path(abreka_dir: Path) -> Path:
    return abreka_dir / "wiki_index.json"


def wiki_page_path(abreka_dir: Path, slug: str) -> Path:
    return wiki_pages_dir(abreka_dir) / f"{slug}.md"


def _isoformat(dt: datetime) -> str:
    return dt.isoformat()


def _parse_dt(value) -> datetime:
    if isinstance(value, datetime):
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value
    return datetime.fromisoformat(str(value))


def serialize_page(page: WikiPage) -> str:
    """Render a WikiPage to its on-disk markdown form."""
    front = {
        "slug": page.slug,
        "title": page.title,
        "summary": page.summary,
        "tags": list(page.tags),
        "created_at": _isoformat(page.created_at),
        "updated_at": _isoformat(page.updated_at),
    }
    if page.created_by_exp:
        front["created_by_exp"] = page.created_by_exp
    if page.updated_by_exp:
        front["updated_by_exp"] = page.updated_by_exp

    buf = io.BytesIO()
    tomli_w.dump(front, buf)
    front_text = buf.getvalue().decode("utf-8").rstrip("\n")

    body = page.body.rstrip("\n")
    return f"{_FRONTMATTER_DELIM}\n{front_text}\n{_FRONTMATTER_DELIM}\n\n{body}\n"


def parse_page(slug: str, text: str) -> WikiPage:
    """Parse the on-disk markdown form into a WikiPage."""
    lines = text.splitlines()
    if not lines or lines[0].strip() != _FRONTMATTER_DELIM:
        raise WikiError(f"Wiki page {slug!r} is missing a frontmatter header.")

    closing = None
    for i, line in enumerate(lines[1:], start=1):
        if line.strip() == _FRONTMATTER_DELIM:
            closing = i
            break
    if closing is None:
        raise WikiError(f"Wiki page {slug!r} has unterminated frontmatter.")

    front_text = "\n".join(lines[1:closing])
    body = "\n".join(lines[closing + 1:]).lstrip("\n")
    try:
        meta = tomllib.loads(front_text)
    except tomllib.TOMLDecodeError as exc:
        raise WikiError(f"Wiki page {slug!r} has invalid frontmatter: {exc}") from exc

    return WikiPage(
        slug=meta.get("slug", slug),
        title=meta.get("title", ""),
        summary=meta.get("summary", ""),
        tags=list(meta.get("tags", [])),
        body=body,
        created_at=_parse_dt(meta.get("created_at", datetime.now(timezone.utc))),
        updated_at=_parse_dt(meta.get("updated_at", datetime.now(timezone.utc))),
        created_by_exp=str(meta.get("created_by_exp", "")),
        updated_by_exp=str(meta.get("updated_by_exp", "")),
    )


def load_wiki_page(abreka_dir: Path, slug: str) -> WikiPage:
    slug = normalize_slug(slug)
    path = wiki_page_path(abreka_dir, slug)
    if not path.is_file():
        raise WikiPageNotFoundError(f"Wiki page {slug!r} not found.")
    return parse_page(slug, path.read_text())


def list_wiki_slugs(abreka_dir: Path) -> list[str]:
    pages_dir = wiki_pages_dir(abreka_dir)
    if not pages_dir.is_dir():
        return []
    return sorted(p.stem for p in pages_dir.glob("*.md"))


def load_wiki_pages(abreka_dir: Path) -> list[WikiPage]:
    """Load every wiki page from disk, ordered by slug."""
    return [load_wiki_page(abreka_dir, slug) for slug in list_wiki_slugs(abreka_dir)]


def save_wiki_page(page: WikiPage, abreka_dir: Path) -> None:
    """Write a wiki page to disk and refresh its index entry."""
    page.slug = normalize_slug(page.slug)
    pages_dir = wiki_pages_dir(abreka_dir)
    pages_dir.mkdir(parents=True, exist_ok=True)
    path = wiki_page_path(abreka_dir, page.slug)
    path.write_text(serialize_page(page))
    update_wiki_index_entry(abreka_dir, page)


def delete_wiki_page(abreka_dir: Path, slug: str) -> None:
    slug = normalize_slug(slug)
    path = wiki_page_path(abreka_dir, slug)
    if not path.is_file():
        raise WikiPageNotFoundError(f"Wiki page {slug!r} not found.")
    path.unlink()
    index = load_wiki_index(abreka_dir)
    index.pop(slug, None)
    save_wiki_index(index, abreka_dir)


def load_wiki_index(abreka_dir: Path) -> dict:
    import json

    path = wiki_index_path(abreka_dir)
    if path.is_file():
        with open(path) as f:
            return json.load(f)
    return {}


def save_wiki_index(index: dict, abreka_dir: Path) -> None:
    import json

    path = wiki_index_path(abreka_dir)
    path.parent.mkdir(parents=True, exist_ok=True)
    lock_path = wiki_dir(abreka_dir) / ".wiki_index.lock"
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    with open(lock_path, "a+") as lock_file:
        if fcntl is not None:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
        with open(path, "w") as f:
            json.dump(index, f, indent=2, sort_keys=True)
            f.write("\n")


def update_wiki_index_entry(abreka_dir: Path, page: WikiPage) -> None:
    index = load_wiki_index(abreka_dir)
    index[page.slug] = page.index_entry()
    save_wiki_index(index, abreka_dir)


def rebuild_wiki_index(abreka_dir: Path) -> dict:
    """Rebuild the wiki index by scanning all pages on disk."""
    index: dict = {}
    for slug in list_wiki_slugs(abreka_dir):
        page = load_wiki_page(abreka_dir, slug)
        index[page.slug] = page.index_entry()
    save_wiki_index(index, abreka_dir)
    return index


def append_history(
    page: WikiPage,
    note: str,
    *,
    exp_id: str = "",
    when: datetime | None = None,
) -> None:
    """Append a dated entry under a ``## History`` section in-place.

    Mutates ``page.body``, ``page.updated_at``, and ``page.updated_by_exp``.
    """
    when = when or datetime.now(timezone.utc)
    stamp = when.date().isoformat()
    by = f" (exp {exp_id})" if exp_id else ""
    line = f"- {stamp}{by}: {note.strip()}"

    body = page.body.rstrip()
    if "## History" in body:
        page.body = body + "\n" + line + "\n"
    else:
        sep = "\n\n" if body else ""
        page.body = f"{body}{sep}## History\n\n{line}\n"

    page.updated_at = when
    if exp_id:
        page.updated_by_exp = exp_id


def merge_pages(
    abreka_dir: Path,
    src_slug: str,
    dst_slug: str,
    *,
    exp_id: str = "",
) -> WikiPage:
    """Append src's body into dst, then delete src. Returns the updated dst."""
    src_slug = normalize_slug(src_slug)
    dst_slug = normalize_slug(dst_slug)
    if src_slug == dst_slug:
        raise WikiError("Cannot merge a page into itself.")
    src = load_wiki_page(abreka_dir, src_slug)
    dst = load_wiki_page(abreka_dir, dst_slug)

    sep = "\n\n" if dst.body.strip() else ""
    merged_section = (
        f"## Merged from {src.slug}\n\n"
        f"{src.body.strip()}\n"
    )
    dst.body = f"{dst.body.rstrip()}{sep}{merged_section}"
    if exp_id:
        dst.updated_by_exp = exp_id
    dst.updated_at = datetime.now(timezone.utc)
    # Union tags
    seen = set(dst.tags)
    for t in src.tags:
        if t not in seen:
            dst.tags.append(t)
            seen.add(t)
    save_wiki_page(dst, abreka_dir)
    delete_wiki_page(abreka_dir, src_slug)
    return dst


def rename_page(
    abreka_dir: Path,
    old_slug: str,
    new_slug: str,
    *,
    exp_id: str = "",
) -> WikiPage:
    """Rename a wiki page's slug on disk and in the index."""
    old_slug = normalize_slug(old_slug)
    new_slug = normalize_slug(new_slug)
    if old_slug == new_slug:
        raise WikiError("New slug is the same as the old slug.")
    page = load_wiki_page(abreka_dir, old_slug)
    if wiki_page_path(abreka_dir, new_slug).is_file():
        raise WikiPageExistsError(f"Wiki page {new_slug!r} already exists.")
    page.slug = new_slug
    if exp_id:
        page.updated_by_exp = exp_id
    page.updated_at = datetime.now(timezone.utc)
    save_wiki_page(page, abreka_dir)
    # Remove the old file and its index entry
    delete_wiki_page(abreka_dir, old_slug)
    return page


def collect_tags(abreka_dir: Path) -> dict[str, int]:
    """Return a tag -> page-count map."""
    counts: dict[str, int] = {}
    index = load_wiki_index(abreka_dir)
    for entry in index.values():
        for tag in entry.get("tags", []):
            counts[tag] = counts.get(tag, 0) + 1
    return counts
