"""
Documentation sink providers (Notion, Outline, Confluence).

Provides lightweight DocsSinkProvider implementations for idempotent page upserts
with conservative rate limit handling.
"""

from __future__ import annotations

import html as html_lib
import logging
import re
import time
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any, Dict, Iterable, List, Mapping, Optional, Protocol, Tuple

import requests

from g_gremlin.contracts import BatchOutcome


NOTION_API_BASE = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-28"
NOTION_LARGE_REPLACE_WARNING_BLOCKS = 150
OUTLINE_DEFAULT_API_BASE = "https://app.getoutline.com/api"
OUTLINE_EXTERNAL_ID_MARKER = "external_id:"
OUTLINE_METADATA_SENTINEL = "<!-- g-gremlin:metadata -->"
CONFLUENCE_DEFAULT_API_SUFFIX = "/wiki/rest/api"
CONFLUENCE_EXTERNAL_ID_KEY = "g_gremlin_external_id"
CONFLUENCE_MARKER_PREFIX = "g-gremlin external_id: "
CONFLUENCE_VOID_TAGS = (
    "area",
    "base",
    "br",
    "col",
    "hr",
    "img",
    "input",
    "link",
    "meta",
    "param",
    "source",
    "track",
    "wbr",
)

LOGGER = logging.getLogger(__name__)


class DocsProvider(str, Enum):
    NOTION = "notion"
    OUTLINE = "outline"
    WORD = "word"
    CONFLUENCE = "confluence"


@dataclass
class DocsPage:
    """Page to create or update."""

    external_id: str
    title: str
    content_markdown: str
    parent_id: Optional[str]
    properties: Dict[str, Any] = field(default_factory=dict)
    tags: List[str] = field(default_factory=list)


@dataclass
class DocsPublishResult:
    """Per-page publish outcome."""

    external_id: str
    status: str
    page_id: Optional[str]
    page_url: Optional[str]
    error: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)


@dataclass
class DocsPublishLogEntry:
    external_id: str
    correlation_id: str
    provider: str
    page_id: Optional[str]
    page_url: Optional[str]
    status: str
    error_message: Optional[str] = None
    created_at: float = field(default_factory=lambda: time.time())


class DocsSinkProvider(Protocol):
    """Interface for documentation sinks."""

    @property
    def name(self) -> str:
        ...

    @property
    def provider_type(self) -> DocsProvider:
        ...

    def test_connection(self) -> Dict[str, Any]:
        ...

    def upsert_page(self, page: DocsPage) -> DocsPublishResult:
        ...

    def upsert_pages_batch(self, pages: List[DocsPage]) -> BatchOutcome:
        ...

    def get_page_by_external_id(self, external_id: str) -> Optional[Dict[str, Any]]:
        ...

    def delete_page(self, page_id: str) -> bool:
        ...


class DocsSinkError(RuntimeError):
    """Generic docs sink error."""


def _xhtml_clean(html: str) -> str:
    if not html:
        return html
    tag_group = "|".join(CONFLUENCE_VOID_TAGS)
    pattern = re.compile(rf"<({tag_group})(\\s[^>]*?)?>", re.IGNORECASE)

    def _self_close(match: re.Match[str]) -> str:
        raw = match.group(0)
        if raw.endswith("/>"):
            return raw
        return raw[:-1] + " />"

    return pattern.sub(_self_close, html)


def _slugify_label(tag: str) -> str:
    cleaned = re.sub(r"[^a-z0-9_-]+", "-", tag.strip().lower())
    cleaned = re.sub(r"-+", "-", cleaned).strip("-")
    return cleaned[:255]


def _build_markdown_renderer() -> "MarkdownIt":
    try:
        from markdown_it import MarkdownIt
    except ImportError as exc:  # pragma: no cover - optional dependency
        raise DocsSinkError(
            "markdown-it-py is required for Confluence markdown rendering. "
            "Install with: pip install g-gremlin[docs-confluence]"
        ) from exc

    md = MarkdownIt("commonmark", {"html": True, "linkify": True, "xhtmlOut": True})

    try:
        from mdit_py_plugins.container import container_plugin
    except ImportError as exc:  # pragma: no cover - optional dependency
        raise DocsSinkError(
            "mdit-py-plugins is required for Confluence containers. "
            "Install with: pip install g-gremlin[docs-confluence]"
        ) from exc

    def _container_render(self, tokens, idx, options, env):  # type: ignore[no-untyped-def]
        token = tokens[idx]
        info = token.info.strip() if token.info else ""
        if token.nesting == 1:
            if info.startswith("expand"):
                title = info[len("expand"):].strip()
                title_attr = f' data-title="{html_lib.escape(title)}"' if title else ""
                return f'<div class="expand"{title_attr}>'
            cls = info.split()[0].strip() if info else "info"
            return f'<div class="{cls}">'
        return "</div>"

    for name in ("info", "note", "tip", "warning", "caution", "important"):
        md.use(container_plugin, name, render=_container_render)

    md.use(
        container_plugin,
        "expand",
        render=_container_render,
        validate=lambda params, markup: params.strip().startswith("expand"),
    )

    return md


def _transform_container_divs(html: str) -> str:
    mapping = {
        "info": "info",
        "note": "note",
        "tip": "tip",
        "warning": "warning",
        "caution": "warning",
        "important": "note",
    }

    for cls, macro in mapping.items():
        pattern = re.compile(rf'<div class="{cls}">\s*(.*?)\s*</div>', re.DOTALL)

        def _wrap(match: re.Match[str]) -> str:
            inner = match.group(1)
            return (
                f'<ac:structured-macro ac:name="{macro}">'
                f'<ac:rich-text-body>{inner}</ac:rich-text-body>'
                "</ac:structured-macro>"
            )

        html = pattern.sub(_wrap, html)

    expand_pattern = re.compile(r'<div class="expand"(?:\s+data-title="([^"]*)")?>\s*(.*?)\s*</div>', re.DOTALL)

    def _expand_wrap(match: re.Match[str]) -> str:
        title = match.group(1)
        inner = match.group(2)
        title_param = f'<ac:parameter ac:name="title">{html_lib.escape(title)}</ac:parameter>' if title else ""
        return (
            '<ac:structured-macro ac:name="expand">'
            f"{title_param}"
            f'<ac:rich-text-body>{inner}</ac:rich-text-body>'
            "</ac:structured-macro>"
        )

    html = expand_pattern.sub(_expand_wrap, html)
    return html


def _transform_blockquotes(html: str) -> str:
    def _replace(match: re.Match[str]) -> str:
        inner = match.group(1).strip()
        first_para_match = re.match(r"<p>(.*?)</p>(.*)", inner, re.DOTALL)
        macro = "info"
        if first_para_match:
            first_para = first_para_match.group(1).strip()
            rest = first_para_match.group(2).strip()
            strong_match = re.match(r"^\s*<strong>([^<:]+):</strong>\s*(.*)$", first_para, re.DOTALL)
            if strong_match:
                label = strong_match.group(1).strip().lower()
                remainder = strong_match.group(2).strip()
                macro_map = {
                    "note": "note",
                    "important": "note",
                    "warning": "warning",
                    "caution": "warning",
                    "tip": "tip",
                }
                macro = macro_map.get(label, "info")
                rebuilt = []
                if remainder:
                    rebuilt.append(f"<p>{remainder}</p>")
                if rest:
                    rebuilt.append(rest)
                inner = "\n".join(rebuilt) if rebuilt else ""
        return (
            f'<ac:structured-macro ac:name="{macro}">'
            f'<ac:rich-text-body>{inner}</ac:rich-text-body>'
            "</ac:structured-macro>"
        )

    return re.sub(r"<blockquote>\s*(.*?)\s*</blockquote>", _replace, html, flags=re.DOTALL)


def _transform_status_lozenges(html: str) -> str:
    placeholders: List[str] = []

    def _stash(match: re.Match[str]) -> str:
        placeholders.append(match.group(0))
        return f"__GG_CODE_BLOCK_{len(placeholders) - 1}__"

    html = re.sub(r"<pre[^>]*>.*?</pre>", _stash, html, flags=re.DOTALL)
    html = re.sub(r"<code[^>]*>.*?</code>", _stash, html, flags=re.DOTALL)

    def _replace(match: re.Match[str]) -> str:
        status_text = match.group(1).strip().upper()
        color_map = {
            "ON TRACK": "Green",
            "AT RISK": "Yellow",
            "OFF TRACK": "Red",
            "BLOCKED": "Red",
            "DONE": "Blue",
            "DRAFT": "Grey",
        }
        color = color_map.get(status_text, "Grey")
        return (
            '<ac:structured-macro ac:name="status">'
            f'<ac:parameter ac:name="title">{status_text}</ac:parameter>'
            f'<ac:parameter ac:name="colour">{color}</ac:parameter>'
            "</ac:structured-macro>"
        )

    html = re.sub(r"\[STATUS:\s*([A-Za-z0-9 _-]+)\]", _replace, html)

    for idx, block in enumerate(placeholders):
        html = html.replace(f"__GG_CODE_BLOCK_{idx}__", block)
    return html


def _inject_toc(html: str) -> str:
    if 'ac:name="toc"' in html:
        return html
    header_count = len(re.findall(r"<h[1-3]\b", html))
    if header_count >= 3:
        toc_macro = '<p><ac:structured-macro ac:name="toc"/></p>'
        return f"{toc_macro}{html}"
    return html


def _apply_confluence_style(html: str) -> str:
    html = _transform_container_divs(html)
    html = _transform_blockquotes(html)
    html = _transform_status_lozenges(html)
    html = _inject_toc(html)
    return html


def markdown_to_storage(markdown: str, external_id: Optional[str] = None) -> str:
    md = _build_markdown_renderer()
    html = md.render(markdown or "")
    html = _apply_confluence_style(html)
    html = _xhtml_clean(html)

    if external_id:
        marker = f"{CONFLUENCE_MARKER_PREFIX}{external_id}"
        marker_comment = f"<!-- {marker} -->"
        marker_block = f'{marker_comment}\n<p style="display:none" data-g-gremlin="external-id">{marker}</p>'
        if html.strip():
            html = f"{html.rstrip()}\n{marker_block}\n"
        else:
            html = f"{marker_block}\n"
    return html


class NotionClient(DocsSinkProvider):
    """Notion provider implementation with schema-aware writes and read helpers."""

    def __init__(self, token: str, database_id: Optional[str] = None) -> None:
        self.token = token
        self.database_id = database_id
        self.session = requests.Session()
        self._database_schema_cache: Dict[str, Dict[str, Any]] = {}

    @property
    def name(self) -> str:  # pragma: no cover - trivial
        return "notion"

    @property
    def provider_type(self) -> DocsProvider:  # pragma: no cover - trivial
        return DocsProvider.NOTION

    def _headers(self) -> Dict[str, str]:
        return {
            "Authorization": f"Bearer {self.token}",
            "Notion-Version": NOTION_VERSION,
            "Content-Type": "application/json",
        }

    def _request(
        self,
        method: str,
        path: str,
        json: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> requests.Response:
        url = f"{NOTION_API_BASE}{path}"
        resp = self.session.request(
            method,
            url,
            headers=self._headers(),
            json=json,
            params=params,
            timeout=30,
        )
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", "1"))
            time.sleep(retry_after)
            resp = self.session.request(
                method,
                url,
                headers=self._headers(),
                json=json,
                params=params,
                timeout=30,
            )
        return resp

    def test_connection(self) -> Dict[str, Any]:
        resp = self._request("GET", "/users/me")
        if resp.status_code == 401:
            raise DocsSinkError("Notion auth failed")
        if not resp.ok:
            raise DocsSinkError(f"Notion connection failed ({resp.status_code})")
        data = resp.json()
        return {"status": "ok", "bot": data.get("bot"), "user": data.get("user")}

    def get_database(self, database_id: Optional[str] = None) -> Dict[str, Any]:
        resolved_database_id = database_id or self.database_id
        if not resolved_database_id:
            raise DocsSinkError("Notion database_id is required.")
        resp = self._request("GET", f"/databases/{resolved_database_id}")
        if not resp.ok:
            raise DocsSinkError(f"Notion database lookup failed ({resp.status_code})")
        return resp.json()

    def get_database_schema(self, database_id: Optional[str] = None) -> Dict[str, Any]:
        resolved_database_id = database_id or self.database_id
        if not resolved_database_id:
            raise DocsSinkError("Notion database_id is required.")
        cached = self._database_schema_cache.get(resolved_database_id)
        if cached is not None:
            return cached

        database = self.get_database(resolved_database_id)
        properties = database.get("properties") or {}
        schema = {
            "id": database.get("id", resolved_database_id),
            "title": self._plain_text_from_rich_text(database.get("title") or []),
            "properties": properties,
            "title_property": self._find_property_by_type(properties, "title"),
            "external_id_property": self._find_external_id_property(properties),
            "tags_property": self._find_tags_property(properties),
        }
        self._database_schema_cache[resolved_database_id] = schema
        return schema

    def list_databases(self, query: Optional[str] = None, limit: int = 25) -> List[Dict[str, Any]]:
        results: List[Dict[str, Any]] = []
        start_cursor: Optional[str] = None

        while len(results) < limit:
            payload: Dict[str, Any] = {
                "filter": {"property": "object", "value": "database"},
                "page_size": min(100, limit - len(results)),
            }
            if query:
                payload["query"] = query
            if start_cursor:
                payload["start_cursor"] = start_cursor

            resp = self._request("POST", "/search", json=payload)
            if not resp.ok:
                raise DocsSinkError(f"Notion database search failed ({resp.status_code})")

            data = resp.json()
            results.extend(data.get("results", []))
            if not data.get("has_more") or not data.get("next_cursor"):
                break
            start_cursor = data.get("next_cursor")

        return [self._simplify_database(database) for database in results[:limit]]

    def _get_page_by_external_id_in_database(
        self,
        external_id: str,
        database_id: Optional[str],
    ) -> Optional[Dict[str, Any]]:
        if not database_id:
            return None
        schema = self.get_database_schema(database_id)
        external_id_property = schema.get("external_id_property")
        if not external_id_property:
            raise DocsSinkError(
                "Notion database must include an 'external_id' style property for idempotent upserts."
            )

        properties = schema.get("properties") or {}
        payload = {
            "filter": self._build_external_id_filter(
                str(external_id_property),
                properties.get(str(external_id_property), {}),
                external_id,
            ),
            "page_size": 1,
        }
        resp = self._request("POST", f"/databases/{database_id}/query", json=payload)
        if not resp.ok:
            return None
        results = resp.json().get("results", [])
        return results[0] if results else None

    def get_page_by_external_id(self, external_id: str) -> Optional[Dict[str, Any]]:
        return self._get_page_by_external_id_in_database(external_id, self.database_id)

    def upsert_page(self, page: DocsPage) -> DocsPublishResult:
        database_id = page.parent_id or self.database_id
        if not database_id:
            raise DocsSinkError("Notion database_id is required.")

        schema = self.get_database_schema(database_id)
        existing = self._get_page_by_external_id_in_database(page.external_id, database_id)
        properties = self._build_properties(page, schema=schema)
        if existing:
            page_id = existing.get("id")
            update_payload = {
                "properties": properties,
            }
            resp = self._request("PATCH", f"/pages/{page_id}", json=update_payload)
            if not resp.ok:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=page_id,
                    page_url=None,
                    error=f"HTTP {resp.status_code}",
                )
            try:
                self._replace_page_blocks(str(page_id), page.content_markdown)
            except DocsSinkError as exc:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=page_id,
                    page_url=existing.get("url"),
                    error=str(exc),
                )
            data = resp.json() if resp.content else {}
            url = data.get("url") or existing.get("url")
            return DocsPublishResult(
                external_id=page.external_id,
                status="updated",
                page_id=page_id,
                page_url=url,
            )

        blocks = self._markdown_to_blocks(page.content_markdown)
        create_payload = {
            "parent": {"database_id": database_id},
            "properties": properties,
        }
        if blocks:
            create_payload["children"] = blocks[:100]
        resp = self._request("POST", "/pages", json=create_payload)
        if not resp.ok:
            return DocsPublishResult(
                external_id=page.external_id,
                status="failed",
                page_id=None,
                page_url=None,
                error=f"HTTP {resp.status_code}",
            )
        data = resp.json()
        page_id = data.get("id")
        if page_id and len(blocks) > 100:
            try:
                self._append_block_children(str(page_id), blocks[100:])
            except DocsSinkError as exc:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=page_id,
                    page_url=data.get("url"),
                    error=str(exc),
                )
        return DocsPublishResult(
            external_id=page.external_id,
            status="created",
            page_id=page_id,
            page_url=data.get("url"),
        )

    def upsert_pages_batch(self, pages: List[DocsPage]) -> BatchOutcome:
        outcome = BatchOutcome()
        for page in pages:
            result = self.upsert_page(page)
            if result.status in ("created", "updated"):
                outcome.succeeded.append(result.to_dict())
            elif result.status == "failed":
                outcome.failed.append(result.to_dict())
            else:
                outcome.retryable_failed.append(result.to_dict())
        return outcome

    def delete_page(self, page_id: str) -> bool:
        resp = self._request("PATCH", f"/pages/{page_id}", json={"archived": True})
        return resp.ok

    def get_page(self, page_id: str) -> Dict[str, Any]:
        resp = self._request("GET", f"/pages/{page_id}")
        if not resp.ok:
            raise DocsSinkError(f"Notion page lookup failed ({resp.status_code})")
        return resp.json()

    def query_database_pages(self, database_id: Optional[str] = None, limit: int = 25) -> List[Dict[str, Any]]:
        resolved_database_id = database_id or self.database_id
        if not resolved_database_id:
            raise DocsSinkError("Notion database_id is required.")

        results: List[Dict[str, Any]] = []
        start_cursor: Optional[str] = None

        while len(results) < limit:
            payload: Dict[str, Any] = {
                "page_size": min(100, limit - len(results)),
            }
            if start_cursor:
                payload["start_cursor"] = start_cursor

            resp = self._request("POST", f"/databases/{resolved_database_id}/query", json=payload)
            if not resp.ok:
                raise DocsSinkError(f"Notion database query failed ({resp.status_code})")

            data = resp.json()
            results.extend(data.get("results", []))
            if not data.get("has_more") or not data.get("next_cursor"):
                break
            start_cursor = data.get("next_cursor")

        return results[:limit]

    def search_pages(
        self,
        query: str,
        database_id: Optional[str] = None,
        limit: int = 25,
    ) -> List[Dict[str, Any]]:
        results: List[Dict[str, Any]] = []
        start_cursor: Optional[str] = None

        while len(results) < limit:
            payload: Dict[str, Any] = {
                "query": query,
                "filter": {"property": "object", "value": "page"},
                "page_size": min(100, limit - len(results)),
            }
            if start_cursor:
                payload["start_cursor"] = start_cursor

            resp = self._request("POST", "/search", json=payload)
            if not resp.ok:
                raise DocsSinkError(f"Notion page search failed ({resp.status_code})")

            data = resp.json()
            page_results = data.get("results", [])
            if database_id:
                page_results = [
                    item for item in page_results if self._page_belongs_to_database(item, database_id)
                ]
            results.extend(page_results)
            if not data.get("has_more") or not data.get("next_cursor"):
                break
            start_cursor = data.get("next_cursor")

        return results[:limit]

    def get_page_content(self, page_id: str) -> List[Dict[str, Any]]:
        return self._populate_block_children(self._list_block_children(page_id))

    def simplify_page(self, page: Mapping[str, Any]) -> Dict[str, Any]:
        parent = page.get("parent") or {}
        return {
            "id": page.get("id"),
            "url": page.get("url"),
            "title": self._extract_page_title(page),
            "database_id": parent.get("database_id") or parent.get("data_source_id"),
            "created_time": page.get("created_time"),
            "last_edited_time": page.get("last_edited_time"),
            "properties": self._simplify_page_properties(page),
        }

    def blocks_to_plain_text(self, blocks: Iterable[Mapping[str, Any]]) -> str:
        lines: List[str] = []
        for block in blocks:
            lines.extend(self._block_to_plain_text_lines(block))
        text = "\n".join(line for line in lines if line is not None).strip()
        return f"{text}\n" if text else ""

    def _build_properties(self, page: DocsPage, schema: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
        if schema is None:
            return self._build_legacy_properties(page)

        schema_properties = schema.get("properties") or {}
        title_property = schema.get("title_property")
        external_id_property = schema.get("external_id_property")
        if not title_property:
            raise DocsSinkError("Notion database is missing a title property.")
        if not external_id_property:
            raise DocsSinkError(
                "Notion database must include an 'external_id' style property for idempotent upserts."
            )

        props: Dict[str, Any] = {}
        matched: set[str] = set()

        title_payload = self._property_value_payload(page.title or "Untitled", schema_properties.get(title_property, {}))
        if title_payload is not None:
            props[title_property] = title_payload
            matched.add(str(title_property))

        external_id_payload = self._property_value_payload(page.external_id, schema_properties.get(external_id_property, {}))
        if external_id_payload is not None:
            props[external_id_property] = external_id_payload
            matched.add(str(external_id_property))

        for key, value in page.properties.items():
            property_name = self._match_schema_property_name(key, schema_properties)
            if not property_name or property_name in matched:
                continue
            payload = self._property_value_payload(value, schema_properties.get(property_name, {}))
            if payload is not None:
                props[property_name] = payload
                matched.add(property_name)

        tags_property = schema.get("tags_property")
        if tags_property and page.tags and tags_property not in matched:
            property_meta = schema_properties.get(tags_property, {})
            raw_value: Any = page.tags
            if property_meta.get("type") in {"select", "status"}:
                raw_value = page.tags[0]
            payload = self._property_value_payload(raw_value, property_meta)
            if payload is not None:
                props[tags_property] = payload

        return props

    def _build_legacy_properties(self, page: DocsPage) -> Dict[str, Any]:
        props: Dict[str, Any] = {
            "Name": {
                "title": self._rich_text_value(page.title or "Untitled"),
            },
            "external_id": {
                "rich_text": self._rich_text_value(page.external_id),
            },
        }
        for key, value in page.properties.items():
            props[key] = {"rich_text": self._rich_text_value(str(value))}
        if page.tags:
            props["Tags"] = {"multi_select": [{"name": tag} for tag in page.tags]}
        return props

    def _markdown_to_blocks(self, markdown: str) -> List[Dict[str, Any]]:
        if not markdown:
            return []
        blocks: List[Dict[str, Any]] = []
        in_code_block = False
        code_language = "plain text"
        code_lines: List[str] = []

        for line in markdown.splitlines():
            content = line.strip()

            if content.startswith("```"):
                if in_code_block:
                    blocks.extend(self._build_code_blocks("\n".join(code_lines), code_language))
                    in_code_block = False
                    code_language = "plain text"
                    code_lines = []
                else:
                    in_code_block = True
                    code_language = self._normalize_notion_code_language(content[3:].strip())
                    code_lines = []
                continue

            if in_code_block:
                code_lines.append(line)
                continue

            if not content:
                continue

            block_type, block_content = self._line_to_notion_block(content)
            blocks.append(
                {
                    "object": "block",
                    "type": block_type,
                    block_type: {
                        "rich_text": [{"type": "text", "text": {"content": block_content[:2000]}}],
                    },
                }
            )

        if in_code_block:
            blocks.extend(self._build_code_blocks("\n".join(code_lines), code_language))

        return blocks

    def _list_block_children(self, block_id: str) -> List[Dict[str, Any]]:
        blocks: List[Dict[str, Any]] = []
        start_cursor: Optional[str] = None

        while True:
            params: Dict[str, Any] = {"page_size": 100}
            if start_cursor:
                params["start_cursor"] = start_cursor

            resp = self._request("GET", f"/blocks/{block_id}/children", params=params)
            if not resp.ok:
                raise DocsSinkError(f"Notion block children lookup failed ({resp.status_code})")

            data = resp.json()
            blocks.extend(data.get("results", []))
            if not data.get("has_more") or not data.get("next_cursor"):
                break
            start_cursor = data.get("next_cursor")

        return blocks

    def _populate_block_children(self, blocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        populated: List[Dict[str, Any]] = []
        for block in blocks:
            current = dict(block)
            if current.get("has_children") and current.get("id"):
                current["children"] = self._populate_block_children(
                    self._list_block_children(str(current["id"]))
                )
            populated.append(current)
        return populated

    def _replace_page_blocks(self, page_id: str, markdown: str) -> None:
        existing_blocks = self._list_block_children(page_id)
        if len(existing_blocks) >= NOTION_LARGE_REPLACE_WARNING_BLOCKS:
            LOGGER.warning(
                "Replacing large Notion page body with %s existing blocks for page %s; "
                "updates may be slow because Notion requires per-block deletes.",
                len(existing_blocks),
                page_id,
            )

        for block in existing_blocks:
            block_id = block.get("id")
            if not block_id:
                continue
            resp = self._request("DELETE", f"/blocks/{block_id}")
            if not resp.ok:
                raise DocsSinkError(
                    f"Notion block delete failed ({resp.status_code}). "
                    "Page content may be partially deleted; re-run publish to repair."
                )
        self._append_block_children(page_id, self._markdown_to_blocks(markdown))

    def _append_block_children(self, block_id: str, blocks: List[Dict[str, Any]]) -> None:
        for start in range(0, len(blocks), 100):
            resp = self._request(
                "PATCH",
                f"/blocks/{block_id}/children",
                json={"children": blocks[start:start + 100]},
            )
            if not resp.ok:
                raise DocsSinkError(
                    f"Notion block append failed ({resp.status_code}). "
                    "Page content replacement may be incomplete; re-run publish to repair."
                )

    def _line_to_notion_block(self, content: str) -> Tuple[str, str]:
        if content.startswith("# "):
            return "heading_1", content[2:].strip() or " "
        if content.startswith("## "):
            return "heading_2", content[3:].strip() or " "
        if content.startswith("### "):
            return "heading_3", content[4:].strip() or " "
        if re.match(r"^\d+\.\s+", content):
            stripped = re.sub(r"^\d+\.\s+", "", content).strip()
            return "numbered_list_item", stripped or " "
        if content.startswith("- ") or content.startswith("* "):
            return "bulleted_list_item", content[2:].strip() or " "
        return "paragraph", content

    def _normalize_notion_code_language(self, language: str) -> str:
        if not language:
            return "plain text"
        normalized = language.lower()
        mapping = {
            "py": "python",
            "js": "javascript",
            "ts": "typescript",
            "sh": "bash",
            "shell": "bash",
            "md": "markdown",
            "mmd": "plain text",
            "mermaid": "plain text",
            "text": "plain text",
            "plaintext": "plain text",
        }
        notion_language = mapping.get(normalized, normalized)
        allowed = {
            "abap", "agda", "arduino", "ascii art", "assembly", "bash", "basic", "bnf", "c", "c#", "c++",
            "clojure", "coffeescript", "coq", "css", "dart", "dhall", "diff", "docker", "ebnf", "elixir", "elm",
            "erlang", "f#", "flow", "fortran", "gherkin", "glsl", "go", "graphql", "groovy", "haskell", "html",
            "idris", "java", "javascript", "json", "julia", "kotlin", "latex", "less", "lisp", "livescript", "lua",
            "makefile", "markdown", "markup", "matlab", "mermaid", "nix", "notion formula", "objective-c",
            "ocaml", "pascal", "perl", "php", "plain text", "powershell", "prolog", "protobuf", "purescript",
            "python", "r", "reason", "ruby", "rust", "sass", "scala", "scheme", "scss", "shell", "solidity",
            "sql", "swift", "toml", "typescript", "vb.net", "verilog", "vhdl", "visual basic", "webassembly",
            "xml", "yaml", "java/c/c++/c#",
        }
        return notion_language if notion_language in allowed else "plain text"

    def _build_external_id_filter(
        self,
        property_name: str,
        property_meta: Mapping[str, Any],
        external_id: str,
    ) -> Dict[str, Any]:
        property_type = property_meta.get("type", "rich_text")
        if property_type == "title":
            return {"property": property_name, "title": {"equals": external_id}}
        if property_type == "number":
            numeric_external_id = self._coerce_number(external_id)
            if numeric_external_id is None:
                raise DocsSinkError("Notion external_id property is numeric, but the supplied external_id is not.")
            return {"property": property_name, "number": {"equals": numeric_external_id}}
        return {"property": property_name, "rich_text": {"equals": external_id}}

    def _property_value_payload(
        self,
        value: Any,
        property_meta: Mapping[str, Any],
    ) -> Optional[Dict[str, Any]]:
        property_type = property_meta.get("type")
        if property_type == "title":
            return {"title": self._rich_text_value(value or "Untitled")}
        if property_type == "rich_text":
            return {"rich_text": self._rich_text_value(value)}
        if property_type == "number":
            return {"number": self._coerce_number(value)}
        if property_type == "checkbox":
            return {"checkbox": self._coerce_bool(value)}
        if property_type == "select":
            selected = self._coerce_optional_string(value)
            return {"select": {"name": selected} if selected else None}
        if property_type == "multi_select":
            return {"multi_select": [{"name": item} for item in self._coerce_string_list(value)]}
        if property_type == "status":
            selected = self._coerce_optional_string(value)
            return {"status": {"name": selected} if selected else None}
        if property_type == "url":
            return {"url": self._coerce_optional_string(value)}
        if property_type == "email":
            return {"email": self._coerce_optional_string(value)}
        if property_type == "phone_number":
            return {"phone_number": self._coerce_optional_string(value)}
        if property_type == "date":
            return {"date": self._coerce_date_value(value)}
        if property_type == "relation":
            return {"relation": [{"id": item} for item in self._coerce_id_list(value)]}
        if property_type == "people":
            return {"people": [{"id": item} for item in self._coerce_id_list(value)]}
        if property_type in {
            "formula",
            "rollup",
            "created_by",
            "created_time",
            "last_edited_by",
            "last_edited_time",
            "unique_id",
            "verification",
            "button",
            "files",
        }:
            return None
        return {"rich_text": self._rich_text_value(value)}

    def _rich_text_value(self, value: Any) -> List[Dict[str, Any]]:
        text = self._coerce_optional_string(value)
        if not text:
            return []
        return [
            {"type": "text", "text": {"content": text[i:i + 2000]}}
            for i in range(0, len(text), 2000)
        ]

    def _coerce_optional_string(self, value: Any) -> Optional[str]:
        if value is None:
            return None
        text = str(value).strip()
        return text or None

    def _coerce_number(self, value: Any) -> Optional[float]:
        if value is None:
            return None
        if isinstance(value, bool):
            return 1.0 if value else 0.0
        if isinstance(value, (int, float)):
            return float(value)
        text = str(value).strip().replace(",", "")
        if not text:
            return None
        try:
            return float(text)
        except ValueError:
            return None

    def _coerce_bool(self, value: Any) -> bool:
        if isinstance(value, bool):
            return value
        if isinstance(value, (int, float)):
            return bool(value)
        text = str(value).strip().lower()
        return text in {"1", "true", "yes", "y", "on", "checked"}

    def _coerce_string_list(self, value: Any) -> List[str]:
        if value is None:
            return []
        if isinstance(value, str):
            parts = [part.strip() for part in re.split(r"[;,]", value) if part.strip()]
            return parts or ([value.strip()] if value.strip() else [])
        if isinstance(value, (list, tuple, set)):
            items: List[str] = []
            for item in value:
                text = self._coerce_optional_string(item)
                if text:
                    items.append(text)
            return items
        text = self._coerce_optional_string(value)
        return [text] if text else []

    def _coerce_id_list(self, value: Any) -> List[str]:
        if isinstance(value, (list, tuple, set)):
            raw_values: Iterable[Any] = value
        else:
            raw_values = self._coerce_string_list(value)
        items: List[str] = []
        for item in raw_values:
            candidate = item.get("id") if isinstance(item, Mapping) else item
            text = self._coerce_optional_string(candidate)
            if text:
                items.append(text)
        return items

    def _coerce_date_value(self, value: Any) -> Optional[Dict[str, Any]]:
        if value is None:
            return None
        if isinstance(value, Mapping):
            start = value.get("start") or value.get("date")
            if not start:
                return None
            payload: Dict[str, Any] = {"start": str(start)}
            if value.get("end"):
                payload["end"] = str(value["end"])
            if value.get("time_zone"):
                payload["time_zone"] = str(value["time_zone"])
            return payload
        text = self._coerce_optional_string(value)
        return {"start": text} if text else None

    def _simplify_database(self, database: Mapping[str, Any]) -> Dict[str, Any]:
        return {
            "id": database.get("id"),
            "title": self._plain_text_from_rich_text(database.get("title") or []),
            "url": database.get("url"),
            "last_edited_time": database.get("last_edited_time"),
            "property_count": len(database.get("properties") or {}),
        }

    def _simplify_page_properties(self, page: Mapping[str, Any]) -> Dict[str, Any]:
        simplified: Dict[str, Any] = {}
        for name, property_value in (page.get("properties") or {}).items():
            if isinstance(property_value, Mapping):
                simplified[name] = self._plain_text_from_property(property_value)
        return simplified

    def _plain_text_from_property(self, property_value: Mapping[str, Any]) -> Any:
        property_type = property_value.get("type")
        if property_type == "title":
            return self._plain_text_from_rich_text(property_value.get("title") or [])
        if property_type == "rich_text":
            return self._plain_text_from_rich_text(property_value.get("rich_text") or [])
        if property_type == "number":
            return property_value.get("number")
        if property_type == "checkbox":
            return property_value.get("checkbox")
        if property_type in {"select", "status"}:
            selected = property_value.get(property_type) or {}
            return selected.get("name")
        if property_type == "multi_select":
            return [item.get("name") for item in property_value.get("multi_select") or [] if item.get("name")]
        if property_type in {"url", "email", "phone_number"}:
            return property_value.get(property_type)
        if property_type == "date":
            return property_value.get("date")
        if property_type in {"relation", "people"}:
            return [item.get("id") for item in property_value.get(property_type) or [] if item.get("id")]
        if property_type in {"created_time", "last_edited_time"}:
            return property_value.get(property_type)
        if property_type in {"created_by", "last_edited_by"}:
            actor = property_value.get(property_type) or {}
            return actor.get("id")
        if property_type == "formula":
            formula = property_value.get("formula") or {}
            return formula.get(formula.get("type"))
        return property_value.get(property_type)

    def _extract_page_title(self, page: Mapping[str, Any]) -> str:
        for property_value in (page.get("properties") or {}).values():
            if isinstance(property_value, Mapping) and property_value.get("type") == "title":
                return self._plain_text_from_rich_text(property_value.get("title") or []) or "Untitled"
        return "Untitled"

    def _plain_text_from_rich_text(self, rich_text: Iterable[Mapping[str, Any]]) -> str:
        parts: List[str] = []
        for item in rich_text:
            plain_text = item.get("plain_text")
            if plain_text:
                parts.append(str(plain_text))
                continue
            text = item.get("text") or {}
            content = text.get("content")
            if content:
                parts.append(str(content))
        return "".join(parts)

    def _block_to_plain_text_lines(self, block: Mapping[str, Any], indent: int = 0) -> List[str]:
        block_type = str(block.get("type") or "")
        block_text = self._plain_text_from_block(block)
        prefix = " " * indent
        lines: List[str]

        if block_type == "heading_1":
            lines = [f"{prefix}# {block_text}".rstrip()]
        elif block_type == "heading_2":
            lines = [f"{prefix}## {block_text}".rstrip()]
        elif block_type == "heading_3":
            lines = [f"{prefix}### {block_text}".rstrip()]
        elif block_type == "bulleted_list_item":
            lines = [f"{prefix}- {block_text}".rstrip()]
        elif block_type == "numbered_list_item":
            lines = [f"{prefix}1. {block_text}".rstrip()]
        elif block_type == "quote":
            lines = [f"{prefix}> {block_text}".rstrip()]
        elif block_type == "to_do":
            checked = bool((block.get("to_do") or {}).get("checked"))
            marker = "x" if checked else " "
            lines = [f"{prefix}- [{marker}] {block_text}".rstrip()]
        elif block_type == "code":
            language = ((block.get("code") or {}).get("language") or "").strip()
            code_lines = block_text.splitlines() or [""]
            lines = [f"{prefix}```{language}".rstrip()]
            lines.extend(f"{prefix}{line}" for line in code_lines)
            lines.append(f"{prefix}```")
        else:
            lines = [f"{prefix}{block_text}".rstrip()] if block_text else []

        child_indent = indent + (2 if block_type in {"bulleted_list_item", "numbered_list_item", "to_do"} else 0)
        for child in block.get("children") or []:
            lines.extend(self._block_to_plain_text_lines(child, child_indent))
        return lines

    def _plain_text_from_block(self, block: Mapping[str, Any]) -> str:
        block_type = str(block.get("type") or "")
        block_data = block.get(block_type) or {}
        if not isinstance(block_data, Mapping):
            return ""
        if block_type == "child_page":
            return str(block_data.get("title") or "")
        if block_type == "table_row":
            return " | ".join(
                self._plain_text_from_rich_text(cell)
                for cell in block_data.get("cells") or []
            )
        return self._plain_text_from_rich_text(block_data.get("rich_text") or [])

    def _page_belongs_to_database(self, page: Mapping[str, Any], database_id: str) -> bool:
        parent = page.get("parent") or {}
        return parent.get("database_id") == database_id or parent.get("data_source_id") == database_id

    def _find_property_by_type(self, properties: Mapping[str, Any], property_type: str) -> Optional[str]:
        for name, meta in properties.items():
            if isinstance(meta, Mapping) and meta.get("type") == property_type:
                return name
        return None

    def _find_external_id_property(self, properties: Mapping[str, Any]) -> Optional[str]:
        for candidate in ("external_id", "External ID", "external-id", "external id"):
            matched = self._match_schema_property_name(candidate, properties)
            if matched:
                return matched
        return None

    def _find_tags_property(self, properties: Mapping[str, Any]) -> Optional[str]:
        for candidate in ("tags", "Tags", "labels", "Labels"):
            matched = self._match_schema_property_name(candidate, properties)
            if not matched:
                continue
            property_type = (properties.get(matched) or {}).get("type")
            if property_type in {"multi_select", "select", "status"}:
                return matched
        return None

    def _match_schema_property_name(
        self,
        key: str,
        properties: Mapping[str, Any],
    ) -> Optional[str]:
        if key in properties:
            return key
        normalized_key = self._normalize_property_name(key)
        for name in properties:
            if self._normalize_property_name(name) == normalized_key:
                return name
        return None

    def _normalize_property_name(self, value: str) -> str:
        return re.sub(r"[^a-z0-9]+", "", value.lower())

    def _build_code_blocks(self, content: str, language: str) -> List[Dict[str, Any]]:
        text = content or " "
        chunks = [text[i:i + 2000] for i in range(0, len(text), 2000)] or [" "]
        blocks: List[Dict[str, Any]] = []
        for chunk in chunks:
            blocks.append(
                {
                    "object": "block",
                    "type": "code",
                    "code": {
                        "rich_text": [{"type": "text", "text": {"content": chunk}}],
                        "language": language,
                    },
                }
            )
        return blocks


class OutlineClient(DocsSinkProvider):
    """Outline provider implementation."""

    def __init__(
        self,
        api_token: str,
        api_base: Optional[str] = None,
        default_collection_id: Optional[str] = None,
    ) -> None:
        self.api_token = api_token
        self.api_base = self._normalize_base(api_base or OUTLINE_DEFAULT_API_BASE)
        self.default_collection_id = default_collection_id
        self.session = requests.Session()

    @property
    def name(self) -> str:  # pragma: no cover - trivial
        return "outline"

    @property
    def provider_type(self) -> DocsProvider:  # pragma: no cover - trivial
        return DocsProvider.OUTLINE

    def _normalize_base(self, api_base: str) -> str:
        base = api_base.rstrip("/")
        if not base.endswith("/api"):
            base = f"{base}/api"
        return base

    def _headers(self) -> Dict[str, str]:
        return {
            "Authorization": f"Bearer {self.api_token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    def _post(self, method: str, payload: Optional[Dict[str, Any]] = None) -> requests.Response:
        path = method if method.startswith("/") else f"/{method}"
        url = f"{self.api_base}{path}"
        resp = self.session.post(url, headers=self._headers(), json=payload or {}, timeout=30)
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", "1"))
            time.sleep(retry_after)
            resp = self.session.post(url, headers=self._headers(), json=payload or {}, timeout=30)
        return resp

    def _response_ok(self, resp: requests.Response) -> bool:
        if not resp.ok:
            return False
        try:
            data = resp.json()
            return bool(data.get("ok", True))
        except Exception:
            return False

    def test_connection(self) -> Dict[str, Any]:
        resp = self._post("/auth.info")
        if resp.status_code == 401:
            raise DocsSinkError("Outline auth failed")
        if not self._response_ok(resp):
            raise DocsSinkError(f"Outline connection failed ({resp.status_code})")
        data = resp.json()
        return {"status": "ok", "team": data.get("data", {}).get("team"), "user": data.get("data", {}).get("user")}

    def list_collections(self) -> List[Dict[str, Any]]:
        """Return all collections from the Outline instance."""
        results: List[Dict[str, Any]] = []
        offset = 0
        limit = 25
        while True:
            resp = self._post("/collections.list", {"offset": offset, "limit": limit})
            if not self._response_ok(resp):
                raise DocsSinkError(f"Outline collections.list failed ({resp.status_code})")
            data = resp.json().get("data", [])
            if not data:
                break
            for col in data:
                results.append({
                    "id": col.get("id"),
                    "name": col.get("name"),
                    "urlId": col.get("urlId"),
                    "description": col.get("description", ""),
                    "documents_count": col.get("documents", {}).get("count") if isinstance(col.get("documents"), dict) else None,
                })
            if len(data) < limit:
                break
            offset += limit
        return results

    def resolve_collection_id(self, id_or_slug: str) -> str:
        """Resolve a collection UUID or URL slug to the API-level UUID.

        Accepts either a full UUID or the short ``urlId`` visible in Outline
        URLs (e.g. ``qbT0rAgCPW``).  Returns the UUID required by the API.
        """
        import re

        _UUID_RE = re.compile(
            r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
        )

        if _UUID_RE.match(id_or_slug):
            return id_or_slug

        collections = self.list_collections()
        for col in collections:
            if col.get("urlId") == id_or_slug or col.get("name", "").lower() == id_or_slug.lower():
                return col["id"]

        raise DocsSinkError(
            f"Could not resolve Outline collection '{id_or_slug}'. "
            "Use a UUID or a valid urlId/name from 'g-gremlin docs collections --provider outline'."
        )

    def get_page_by_external_id(self, external_id: str) -> Optional[Dict[str, Any]]:
        payload: Dict[str, Any] = {"query": external_id}
        if self.default_collection_id:
            payload["collectionId"] = self.default_collection_id
        resp = self._post("/documents.search", payload)
        if not self._response_ok(resp):
            return None
        results = resp.json().get("data", [])
        for item in results:
            document = item.get("document") or {}
            text = document.get("text") or ""
            if self._matches_external_id(text, external_id):
                return document
        return None

    def upsert_page(self, page: DocsPage) -> DocsPublishResult:
        collection_id = page.parent_id or self.default_collection_id
        if not collection_id:
            raise DocsSinkError("Outline collection_id is required.")

        collection_id = self.resolve_collection_id(collection_id)
        content = self._ensure_metadata(page, collection_id)
        existing = self.get_page_by_external_id(page.external_id)

        if existing:
            document_id = existing.get("id")
            payload = {
                "id": document_id,
                "title": page.title,
                "text": content,
                "publish": True,
            }
            resp = self._post("/documents.update", payload)
            if not self._response_ok(resp):
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=document_id,
                    page_url=None,
                    error=f"HTTP {resp.status_code}",
                )
            data = resp.json().get("data", existing)
            return DocsPublishResult(
                external_id=page.external_id,
                status="updated",
                page_id=data.get("id"),
                page_url=self._document_url(data),
            )

        payload = {
            "collectionId": collection_id,
            "title": page.title,
            "text": content,
            "publish": True,
        }
        resp = self._post("/documents.create", payload)
        if not self._response_ok(resp):
            return DocsPublishResult(
                external_id=page.external_id,
                status="failed",
                page_id=None,
                page_url=None,
                error=f"HTTP {resp.status_code}",
            )
        data = resp.json().get("data", {})
        return DocsPublishResult(
            external_id=page.external_id,
            status="created",
            page_id=data.get("id"),
            page_url=self._document_url(data),
        )

    def upsert_pages_batch(self, pages: List[DocsPage]) -> BatchOutcome:
        outcome = BatchOutcome()
        for page in pages:
            result = self.upsert_page(page)
            if result.status in ("created", "updated"):
                outcome.succeeded.append(result.to_dict())
            elif result.status == "failed":
                outcome.failed.append(result.to_dict())
            else:
                outcome.retryable_failed.append(result.to_dict())
        return outcome

    def delete_page(self, page_id: str) -> bool:
        resp = self._post("/documents.delete", {"id": page_id})
        return self._response_ok(resp)

    # ------------------------------------------------------------------
    # Read-only helpers
    # ------------------------------------------------------------------

    def get_document(self, doc_id: str) -> Dict[str, Any]:
        """Fetch a single document by ID (UUID or urlId slug)."""
        resp = self._post("/documents.info", {"id": doc_id})
        if resp.status_code == 404:
            raise DocsSinkError(f"Document '{doc_id}' not found.")
        if not self._response_ok(resp):
            raise DocsSinkError(
                f"Outline documents.info failed ({resp.status_code})"
            )
        return resp.json().get("data", {})

    def search_documents(
        self,
        query: str,
        collection_id: Optional[str] = None,
        limit: int = 25,
    ) -> List[Dict[str, Any]]:
        """Full-text search via /documents.search."""
        payload: Dict[str, Any] = {"query": query, "limit": min(limit, 25)}
        effective = collection_id or self.default_collection_id
        if effective:
            payload["collectionId"] = effective
        resp = self._post("/documents.search", payload)
        if not self._response_ok(resp):
            raise DocsSinkError(
                f"Outline documents.search failed ({resp.status_code})"
            )
        return resp.json().get("data", [])

    def list_documents(
        self,
        collection_id: Optional[str] = None,
        limit: int = 25,
        offset: int = 0,
    ) -> List[Dict[str, Any]]:
        """List documents via /documents.list."""
        payload: Dict[str, Any] = {"offset": offset, "limit": limit}
        effective = collection_id or self.default_collection_id
        if effective:
            payload["collectionId"] = effective
        resp = self._post("/documents.list", payload)
        if not self._response_ok(resp):
            raise DocsSinkError(
                f"Outline documents.list failed ({resp.status_code})"
            )
        return resp.json().get("data", [])

    def _matches_external_id(self, text: str, external_id: str) -> bool:
        marker = f"{OUTLINE_EXTERNAL_ID_MARKER} {external_id}"
        return marker in text

    def _ensure_metadata(self, page: DocsPage, collection_id: str) -> str:
        content = page.content_markdown or ""
        marker = f"{OUTLINE_EXTERNAL_ID_MARKER} {page.external_id}"
        if marker in content:
            return content

        meta_lines = [
            OUTLINE_METADATA_SENTINEL,
            f"{OUTLINE_EXTERNAL_ID_MARKER} {page.external_id}",
            f"collection_id: {collection_id}",
        ]
        for key, value in page.properties.items():
            meta_lines.append(f"{key}: {value}")
        if page.tags:
            meta_lines.append(f"tags: {', '.join(page.tags)}")

        metadata_block = "<details><summary>Gremlin Metadata</summary>\n" + "\n".join(meta_lines) + "\n</details>"
        if content.strip():
            return f"{content.rstrip()}\n\n{metadata_block}\n"
        return f"{metadata_block}\n"

    def _document_url(self, document: Dict[str, Any]) -> Optional[str]:
        base = self.api_base[:-4] if self.api_base.endswith("/api") else self.api_base
        url_id = document.get("urlId") or document.get("id")
        if base and url_id:
            return f"{base}/doc/{url_id}"
        return None


class ConfluenceClient(DocsSinkProvider):
    """Confluence Cloud provider implementation."""

    def __init__(
        self,
        api_base: str,
        email: str,
        api_token: str,
        default_space_key: Optional[str] = None,
        default_parent_id: Optional[str] = None,
    ) -> None:
        self.api_base = self._normalize_base(api_base)
        self.email = email
        self.api_token = api_token
        self.default_space_key = default_space_key
        self.default_parent_id = default_parent_id
        self.session = requests.Session()

    @property
    def name(self) -> str:  # pragma: no cover - trivial
        return "confluence"

    @property
    def provider_type(self) -> DocsProvider:  # pragma: no cover - trivial
        return DocsProvider.CONFLUENCE

    def _normalize_base(self, api_base: str) -> str:
        base = api_base.rstrip("/")
        if base.endswith(CONFLUENCE_DEFAULT_API_SUFFIX):
            return base
        if base.endswith("/wiki"):
            return f"{base}/rest/api"
        if base.endswith("/rest/api"):
            return base
        return f"{base}{CONFLUENCE_DEFAULT_API_SUFFIX}"

    def _headers(self) -> Dict[str, str]:
        return {
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    def _request(
        self,
        method: str,
        path: str,
        json: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> requests.Response:
        url = f"{self.api_base}{path}"
        resp = self.session.request(
            method,
            url,
            headers=self._headers(),
            json=json,
            params=params,
            auth=(self.email, self.api_token),
            timeout=30,
        )
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", "1"))
            time.sleep(retry_after)
            resp = self.session.request(
                method,
                url,
                headers=self._headers(),
                json=json,
                params=params,
                auth=(self.email, self.api_token),
                timeout=30,
            )
        return resp

    def _error_message(self, resp: requests.Response) -> str:
        try:
            data = resp.json()
        except Exception:
            return f"HTTP {resp.status_code}"
        if isinstance(data, dict):
            if data.get("message"):
                return data["message"]
            if data.get("errorMessages"):
                return "; ".join(data.get("errorMessages"))
            if data.get("errors"):
                return str(data.get("errors"))
        return f"HTTP {resp.status_code}"

    def test_connection(self) -> Dict[str, Any]:
        resp = self._request("GET", "/user/current")
        if resp.status_code == 401:
            raise DocsSinkError("Confluence auth failed")
        if not resp.ok:
            raise DocsSinkError(f"Confluence connection failed ({resp.status_code})")
        data = resp.json()
        return {"status": "ok", "user": data.get("displayName") or data.get("accountId")}

    def get_space(self, space_key: str) -> Optional[Dict[str, Any]]:
        resp = self._request("GET", f"/space/{space_key}")
        if resp.status_code == 404:
            return None
        if not resp.ok:
            return None
        return resp.json()

    def get_content(self, content_id: str, expand: Optional[str] = None) -> Optional[Dict[str, Any]]:
        params = {"expand": expand} if expand else None
        resp = self._request("GET", f"/content/{content_id}", params=params)
        if resp.status_code == 404:
            return None
        if not resp.ok:
            return None
        return resp.json()

    def _search_cql(self, cql: str, limit: int = 1) -> List[Dict[str, Any]]:
        resp = self._request("GET", "/content/search", params={"cql": cql, "limit": limit})
        if not resp.ok:
            return []
        return resp.json().get("results", [])

    def _cql_escape(self, value: str) -> str:
        return value.replace("\\", "\\\\").replace('"', '\\"')

    def get_page_by_external_id(self, external_id: str, space_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
        external_id = str(external_id)
        resolved_space = space_key or self.default_space_key
        if not resolved_space:
            return None
        cql = (
            f'space = "{self._cql_escape(resolved_space)}" '
            f'AND type = page '
            f'AND content.property[{CONFLUENCE_EXTERNAL_ID_KEY}].value = "{self._cql_escape(external_id)}"'
        )
        results = self._search_cql(cql, limit=1)
        if results:
            return results[0]

        marker = f"{CONFLUENCE_MARKER_PREFIX}{external_id}"
        cql = (
            f'space = "{self._cql_escape(resolved_space)}" '
            f'AND type = page '
            f'AND text ~ "{self._cql_escape(marker)}"'
        )
        results = self._search_cql(cql, limit=1)
        return results[0] if results else None

    def _build_storage_body(self, page: DocsPage) -> Dict[str, Any]:
        storage_value = markdown_to_storage(page.content_markdown or "", external_id=page.external_id)
        return {"storage": {"value": storage_value, "representation": "storage"}}

    def _extract_page_url(self, data: Dict[str, Any]) -> Optional[str]:
        links = data.get("_links", {})
        base = links.get("base")
        webui = links.get("webui")
        if base and webui:
            return f"{base}{webui}"
        if links.get("self"):
            return links.get("self")
        return None

    def _get_content_property(self, content_id: str, key: str) -> Optional[Dict[str, Any]]:
        resp = self._request("GET", f"/content/{content_id}/property/{key}")
        if resp.status_code == 404:
            return None
        if not resp.ok:
            return None
        return resp.json()

    def _set_content_property(self, content_id: str, key: str, value: Any) -> bool:
        existing = self._get_content_property(content_id, key)
        if existing and existing.get("value") == value:
            return True
        if existing:
            payload = {
                "id": existing.get("id"),
                "key": key,
                "value": value,
                "version": {"number": existing.get("version", {}).get("number", 1) + 1},
            }
            resp = self._request("PUT", f"/content/{content_id}/property/{key}", json=payload)
            return resp.ok
        payload = {"key": key, "value": value}
        resp = self._request("POST", f"/content/{content_id}/property", json=payload)
        return resp.ok

    def _add_labels(self, content_id: str, tags: List[str]) -> bool:
        if not tags:
            return True
        labels = []
        seen = set()
        for tag in tags:
            slug = _slugify_label(tag)
            if slug and slug not in seen:
                seen.add(slug)
                labels.append({"prefix": "global", "name": slug})
        if not labels:
            return True
        resp = self._request("POST", f"/content/{content_id}/label", json=labels)
        return resp.ok

    def _apply_metadata(self, content_id: str, page: DocsPage) -> Optional[str]:
        if not self._set_content_property(content_id, CONFLUENCE_EXTERNAL_ID_KEY, page.external_id):
            return "Failed to set content property."
        if page.tags and not self._add_labels(content_id, page.tags):
            return "Failed to apply labels."
        return None

    def upsert_page(self, page: DocsPage) -> DocsPublishResult:
        return self._upsert_page(page, allow_conflict_retry=True)

    def _upsert_page(self, page: DocsPage, allow_conflict_retry: bool) -> DocsPublishResult:
        space_key = page.properties.get("space_key") if page.properties else None
        resolved_space = space_key or self.default_space_key
        if not resolved_space:
            raise DocsSinkError("Confluence space_key is required.")

        parent_id = page.parent_id or self.default_parent_id
        existing = self.get_page_by_external_id(page.external_id, space_key=resolved_space)

        if existing:
            content_id = existing.get("id")
            content = self.get_content(content_id, expand="version")
            if not content:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=content_id,
                    page_url=None,
                    error="Failed to fetch current content version.",
                )
            version_number = content.get("version", {}).get("number", 1) + 1
            payload: Dict[str, Any] = {
                "id": content_id,
                "type": "page",
                "title": page.title,
                "space": {"key": resolved_space},
                "version": {"number": version_number},
                "body": self._build_storage_body(page),
            }
            if parent_id:
                payload["ancestors"] = [{"id": parent_id}]
            resp = self._request("PUT", f"/content/{content_id}", json=payload)
            if not resp.ok:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=content_id,
                    page_url=self._extract_page_url(existing),
                    error=self._error_message(resp),
                )
            data = resp.json()
            metadata_error = self._apply_metadata(content_id, page)
            if metadata_error:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="partial",
                    page_id=content_id,
                    page_url=self._extract_page_url(data),
                    error=metadata_error,
                )
            return DocsPublishResult(
                external_id=page.external_id,
                status="updated",
                page_id=content_id,
                page_url=self._extract_page_url(data),
            )

        payload = {
            "type": "page",
            "title": page.title,
            "space": {"key": resolved_space},
            "body": self._build_storage_body(page),
        }
        if parent_id:
            payload["ancestors"] = [{"id": parent_id}]
        resp = self._request("POST", "/content", json=payload)
        if resp.status_code == 409:
            if not allow_conflict_retry:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="failed",
                    page_id=None,
                    page_url=None,
                    error="Conflict creating page.",
                )
            existing = self.get_page_by_external_id(page.external_id, space_key=resolved_space)
            if existing:
                return self._upsert_page(
                    DocsPage(
                        external_id=page.external_id,
                        title=page.title,
                        content_markdown=page.content_markdown,
                        parent_id=parent_id,
                        properties={"space_key": resolved_space},
                        tags=page.tags,
                    ),
                    allow_conflict_retry=False,
                )
        if not resp.ok:
            return DocsPublishResult(
                external_id=page.external_id,
                status="failed",
                page_id=None,
                page_url=None,
                error=self._error_message(resp),
            )
        data = resp.json()
        content_id = data.get("id")
        if content_id:
            metadata_error = self._apply_metadata(content_id, page)
            if metadata_error:
                return DocsPublishResult(
                    external_id=page.external_id,
                    status="partial",
                    page_id=content_id,
                    page_url=self._extract_page_url(data),
                    error=metadata_error,
                )
        return DocsPublishResult(
            external_id=page.external_id,
            status="created",
            page_id=content_id,
            page_url=self._extract_page_url(data),
        )

    def upsert_pages_batch(self, pages: List[DocsPage]) -> BatchOutcome:
        outcome = BatchOutcome()
        for page in pages:
            result = self.upsert_page(page)
            if result.status in ("created", "updated"):
                outcome.succeeded.append(result.to_dict())
            elif result.status == "failed":
                outcome.failed.append(result.to_dict())
            else:
                outcome.retryable_failed.append(result.to_dict())
        return outcome

    def delete_page(self, page_id: str) -> bool:
        resp = self._request("DELETE", f"/content/{page_id}")
        return resp.ok


__all__ = [
    "DocsProvider",
    "DocsPage",
    "DocsPublishResult",
    "DocsPublishLogEntry",
    "DocsSinkProvider",
    "DocsSinkError",
    "NotionClient",
    "OutlineClient",
    "ConfluenceClient",
]
