"""
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 re
import time
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, Tuple

import requests

from g_gremlin.contracts import BatchOutcome


NOTION_API_BASE = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-28"
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",
)


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):
    """Minimal Notion provider implementation."""

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

    @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) -> requests.Response:
        url = f"{NOTION_API_BASE}{path}"
        resp = self.session.request(method, url, headers=self._headers(), json=json, 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, 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_page_by_external_id(self, external_id: str) -> Optional[Dict[str, Any]]:
        if not self.database_id:
            return None
        payload = {
            "filter": {
                "property": "external_id",
                "rich_text": {"equals": external_id},
            },
            "page_size": 1,
        }
        resp = self._request("POST", f"/databases/{self.database_id}/query", json=payload)
        if not resp.ok:
            return None
        results = resp.json().get("results", [])
        return results[0] if results else None

    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.")

        existing = self.get_page_by_external_id(page.external_id)
        if existing:
            page_id = existing.get("id")
            update_payload = {
                "properties": self._build_properties(page),
            }
            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}",
                )
            # Append new content to the page for quick parity
            if page.content_markdown:
                self._append_blocks(page_id, page.content_markdown)
            url = existing.get("url")
            return DocsPublishResult(
                external_id=page.external_id,
                status="updated",
                page_id=page_id,
                page_url=url,
            )

        create_payload = {
            "parent": {"database_id": database_id},
            "properties": self._build_properties(page),
            "children": self._markdown_to_blocks(page.content_markdown),
        }
        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()
        return DocsPublishResult(
            external_id=page.external_id,
            status="created",
            page_id=data.get("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 _build_properties(self, page: DocsPage) -> Dict[str, Any]:
        props: Dict[str, Any] = {
            "Name": {
                "title": [{"type": "text", "text": {"content": page.title[:2000]}}],
            },
            "external_id": {
                "rich_text": [{"type": "text", "text": {"content": page.external_id}}],
            },
        }
        for key, value in page.properties.items():
            props[key] = {"rich_text": [{"type": "text", "text": {"content": str(value)[:2000]}}]}
        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 _append_blocks(self, page_id: str, markdown: str) -> None:
        blocks = self._markdown_to_blocks(markdown)
        if not blocks:
            return
        self._request("PATCH", f"/blocks/{page_id}/children", json={"children": blocks})

    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_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 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.")

        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)

    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",
]
