"""Notion REST API — minimal client for validation and later publishing."""

from __future__ import annotations

import json
import re
import ssl
import time
import urllib.error
import urllib.request
from typing import Any, Mapping
from urllib.parse import quote, unquote, urlparse

NOTION_API = "https://api.notion.com/v1"
# https://developers.notion.com/reference/versioning
NOTION_API_VERSION = "2022-06-28"


class NotionAPIError(Exception):
    """Raised when Notion returns a non-success response or the request fails."""

    def __init__(self, message: str, *, status: int | None = None, body: Any = None):
        super().__init__(message)
        self.status = status
        self.body = body


def _normalize_page_id(page_id: str) -> str:
    raw = page_id.strip().replace("-", "")
    if len(raw) != 32 or any(c not in "0123456789abcdefABCDEF" for c in raw):
        raise ValueError(
            "Notion page id must be a 32-character hex UUID (with or without dashes)."
        )
    r = raw.lower()
    return f"{r[:8]}-{r[8:12]}-{r[12:16]}-{r[16:20]}-{r[20:]}"


def parse_page_id_from_user_input(text: str) -> str:
    """Extract a page UUID from pasted text: full notion.so URL, or raw id."""
    s = (text or "").strip()
    if not s:
        raise ValueError("Empty input.")

    if "notion.so" in s.lower() or s.lower().startswith("http"):
        parsed = urlparse(s)
        path = unquote(parsed.path.rstrip("/"))
        segment = path.split("/")[-1] if path else ""
        # "Title-{uuid}" slug or bare 32-hex segment
        for candidate in (segment, path):
            m = re.search(
                r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
                candidate,
                re.I,
            )
            if m:
                return _normalize_page_id(m.group(1).replace("-", ""))
            m2 = re.search(r"([0-9a-f]{32})", candidate, re.I)
            if m2:
                return _normalize_page_id(m2.group(1))
        raise ValueError(
            "No page id found in that link. Use Share → Copy link, or paste only the id from the URL path."
        )

    m = re.search(
        r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
        s,
        re.I,
    )
    if m:
        return _normalize_page_id(m.group(1).replace("-", ""))
    m2 = re.search(r"([0-9a-f]{32})", s, re.I)
    if m2:
        return _normalize_page_id(m2.group(1))
    raise ValueError(
        "No Notion page id found. Paste the Share → Copy link, or the 32-character id."
    )


_TRANSIENT_STATUS = frozenset({429, 502, 503, 504})


def _notion_request_once(
    method: str,
    path: str,
    token: str,
    *,
    body: Mapping[str, Any] | None = None,
) -> Any:
    url = f"{NOTION_API}{path}"
    data = None
    headers = {
        "Authorization": f"Bearer {token}",
        "Notion-Version": NOTION_API_VERSION,
    }
    if body is not None:
        data = json.dumps(dict(body)).encode("utf-8")
        headers["Content-Type"] = "application/json; charset=utf-8"
    req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
            payload = resp.read().decode("utf-8")
            if not payload:
                return None
            return json.loads(payload)
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        try:
            parsed = json.loads(raw) if raw else None
        except json.JSONDecodeError:
            parsed = raw
        msg: str
        if isinstance(parsed, dict) and parsed.get("message"):
            msg = str(parsed["message"])
        elif isinstance(parsed, str):
            msg = parsed
        else:
            msg = raw or (e.reason or "HTTP error")
        raise NotionAPIError(msg, status=e.code, body=parsed) from e


def notion_request(
    method: str,
    path: str,
    token: str,
    *,
    body: Mapping[str, Any] | None = None,
    max_retries: int = 3,
) -> Any:
    """Send a request to Notion v1 API; retries transient errors (429 / 5xx)."""
    delay = 1.0
    last: NotionAPIError | None = None
    for attempt in range(max_retries + 1):
        try:
            return _notion_request_once(method, path, token, body=body)
        except NotionAPIError as e:
            last = e
            if e.status not in _TRANSIENT_STATUS or attempt >= max_retries:
                raise
            time.sleep(delay)
            delay = min(delay * 2.0, 30.0)
    assert last is not None
    raise last


def verify_integration_token(token: str) -> dict[str, Any]:
    """Confirm the token works; returns the bot `user` object."""
    data = notion_request("GET", "/users/me", token)
    if not isinstance(data, dict):
        raise NotionAPIError("Unexpected response from Notion.")
    return data


def _error_suggests_id_is_database(message: str) -> bool:
    m = message.lower()
    return ("database" in m and "not a page" in m) or "is a database" in m


def verify_page_access(token: str, page_id: str) -> dict[str, Any]:
    """Confirm the integration can read the **page** (not a database)."""
    pid = _normalize_page_id(page_id)
    encoded = quote(pid, safe="-")
    data = notion_request("GET", f"/pages/{encoded}", token)
    if not isinstance(data, dict):
        raise NotionAPIError("Unexpected response when fetching page.")
    return data


def verify_database_access(token: str, database_id: str) -> dict[str, Any]:
    """Confirm the integration can read the database."""
    did = _normalize_page_id(database_id)
    encoded = quote(did, safe="-")
    data = notion_request("GET", f"/databases/{encoded}", token)
    if not isinstance(data, dict):
        raise NotionAPIError("Unexpected response when fetching database.")
    return data


def verify_parent_location(
    token: str, page_id: str
) -> tuple[str, dict[str, Any]]:
    """
    Confirm the integration can read the parent as a **page** or **database**.

    Returns ``("page", obj)`` or ``("database", obj)`` — same id format for both APIs.
    """
    pid = _normalize_page_id(page_id)
    encoded = quote(pid, safe="-")
    try:
        data = notion_request("GET", f"/pages/{encoded}", token)
        if not isinstance(data, dict):
            raise NotionAPIError("Unexpected response when fetching page.")
        return "page", data
    except NotionAPIError as page_err:
        if not _error_suggests_id_is_database(str(page_err)):
            raise
        try:
            ddata = verify_database_access(token, pid)
            return "database", ddata
        except NotionAPIError as db_err:
            raise db_err from page_err


def _database_title_property_key(database: dict[str, Any]) -> str:
    props = database.get("properties")
    if not isinstance(props, dict):
        raise NotionAPIError("Database has no properties schema.")
    for name, schema in props.items():
        if isinstance(schema, dict) and schema.get("type") == "title":
            return name
    raise NotionAPIError("Database has no title property (rename one column to title).")


def _split_text_chunks(text: str, max_len: int = 2000) -> list[str]:
    if not text:
        return []
    return [text[i : i + max_len] for i in range(0, len(text), max_len)]


def _parse_inline_rich_text(s: str) -> list[dict[str, Any]]:
    """Subset: `` `code` ``, **bold** (LLM output from NotebookLM)."""
    segments: list[dict[str, Any]] = []
    buf: list[str] = []
    i = 0
    n = len(s)

    def flush_buf() -> None:
        if not buf:
            return
        text = "".join(buf)
        buf.clear()
        for chunk in _split_text_chunks(text):
            segments.append({"type": "text", "text": {"content": chunk}})

    def add_styled(text: str, **annotations: bool) -> None:
        if not text:
            return
        for chunk in _split_text_chunks(text):
            segments.append(
                {
                    "type": "text",
                    "text": {"content": chunk},
                    "annotations": dict(annotations),
                }
            )

    while i < n:
        if s[i] == "`":
            j = s.find("`", i + 1)
            if j == -1:
                buf.append(s[i])
                i += 1
                continue
            flush_buf()
            add_styled(s[i + 1 : j], code=True)
            i = j + 1
            continue
        if i + 1 < n and s[i : i + 2] == "**":
            j = s.find("**", i + 2)
            if j == -1:
                buf.append(s[i])
                i += 1
                continue
            flush_buf()
            add_styled(s[i + 2 : j], bold=True)
            i = j + 2
            continue
        buf.append(s[i])
        i += 1
    flush_buf()
    if not segments:
        segments.append({"type": "text", "text": {"content": " "}})
    return segments


_HEADING_RE = re.compile(r"^(#{1,3})\s+(.*)$")
_BULLET_RE = re.compile(r"^\s*[-*]\s+(.*)$")
_NUMBER_RE = re.compile(r"^\s*\d+\.\s+(.*)$")
_QUOTE_RE = re.compile(r"^>\s?(.*)$")
_DIVIDER_RE = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")


def body_markdown_to_notion_blocks(body: str) -> list[dict[str, Any]]:
    """
    Turn NotebookLM-style Markdown into Notion block objects
    (headings, bullets, numbered lists, quotes, dividers, **bold**, `` `code` ``).
    """
    lines = body.replace("\r\n", "\n").split("\n")
    blocks: list[dict[str, Any]] = []
    para_buf: list[str] = []

    def flush_paragraph() -> None:
        if not para_buf:
            return
        text = " ".join(p.strip() for p in para_buf if p.strip())
        para_buf.clear()
        if not text:
            return
        blocks.append(
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {"rich_text": _parse_inline_rich_text(text)},
            }
        )

    for line in lines:
        raw = line.rstrip()
        if not raw.strip():
            flush_paragraph()
            continue

        if _DIVIDER_RE.match(raw):
            flush_paragraph()
            blocks.append({"object": "block", "type": "divider", "divider": {}})
            continue

        mh = _HEADING_RE.match(raw)
        if mh:
            flush_paragraph()
            level = len(mh.group(1))
            content = (mh.group(2) or "").strip()
            htype = f"heading_{level}"
            blocks.append(
                {
                    "object": "block",
                    "type": htype,
                    htype: {"rich_text": _parse_inline_rich_text(content or " ")},
                }
            )
            continue

        mb = _BULLET_RE.match(raw)
        if mb:
            flush_paragraph()
            content = (mb.group(1) or "").strip()
            blocks.append(
                {
                    "object": "block",
                    "type": "bulleted_list_item",
                    "bulleted_list_item": {
                        "rich_text": _parse_inline_rich_text(content or " ")
                    },
                }
            )
            continue

        mn = _NUMBER_RE.match(raw)
        if mn:
            flush_paragraph()
            content = (mn.group(1) or "").strip()
            blocks.append(
                {
                    "object": "block",
                    "type": "numbered_list_item",
                    "numbered_list_item": {
                        "rich_text": _parse_inline_rich_text(content or " ")
                    },
                }
            )
            continue

        mq = _QUOTE_RE.match(raw)
        if mq:
            flush_paragraph()
            content = (mq.group(1) or "").strip()
            blocks.append(
                {
                    "object": "block",
                    "type": "quote",
                    "quote": {"rich_text": _parse_inline_rich_text(content or " ")},
                }
            )
            continue

        para_buf.append(raw)

    flush_paragraph()
    if not blocks:
        blocks.append(
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {"rich_text": _parse_inline_rich_text(" ")},
            }
        )
    return blocks


def _append_page_children(
    token: str,
    page_id: str,
    children: list[dict[str, Any]],
    *,
    max_retries: int = 3,
) -> None:
    """Append blocks in batches of 100 (Notion limit)."""
    pid = _normalize_page_id(page_id)
    encoded = quote(pid, safe="-")
    for i in range(0, len(children), 100):
        batch = children[i : i + 100]
        notion_request(
            "PATCH",
            f"/blocks/{encoded}/children",
            token,
            body={"children": batch},
            max_retries=max_retries,
        )


def create_page_under_parent(
    token: str,
    parent_raw_id: str,
    title: str,
    body: str,
    *,
    max_retries: int = 3,
    export_callout: str | None = None,
) -> dict[str, Any]:
    """
    Create a page under a Notion **page** or **database** parent (same id format).

    Renders ``body`` as Markdown-style blocks (headings, lists, bold, etc.).
    Optional ``export_callout`` is shown as a shaded callout above the body.
    """
    kind, meta = verify_parent_location(token, parent_raw_id)
    pid = _normalize_page_id(parent_raw_id)
    children: list[dict[str, Any]] = []
    if export_callout:
        children.append(
            {
                "object": "block",
                "type": "callout",
                "callout": {
                    "rich_text": _parse_inline_rich_text(export_callout),
                    "icon": {"type": "emoji", "emoji": "📄"},
                    "color": "gray_background",
                },
            }
        )
    children.extend(body_markdown_to_notion_blocks(body))
    title_prop = {"title": [{"text": {"content": (title or "Folio export")[:2000]}}]}

    if kind == "database":
        key = _database_title_property_key(meta)
        payload: dict[str, Any] = {
            "parent": {"database_id": pid},
            "properties": {key: title_prop},
        }
    else:
        payload = {
            "parent": {"page_id": pid},
            "properties": {"title": title_prop},
        }

    first_children = children[:100]
    rest_children = children[100:]
    if first_children:
        payload["children"] = first_children

    page = notion_request(
        "POST", "/pages", token, body=payload, max_retries=max_retries
    )
    if not isinstance(page, dict) or "id" not in page:
        raise NotionAPIError("Unexpected create page response.")

    if rest_children:
        _append_page_children(
            token, page["id"], rest_children, max_retries=max_retries
        )

    return page
