#!/usr/bin/env python3
"""
md2confluence-v3.py

Markdown to Confluence storage XML converter with v8.11.0 features:
  - Multipart attachment upload (PNG screenshots)
  - <ac:image ri:filename> injection from markdown image references
  - Mermaid macro support with numbered-list fallback
  - Footnote-style tooltips (^[text]) rendered as tooltip macro with fallback
  - YAML front-matter parsing for backend metadata + labels
  - Pre-conversion punctuation gate (em-dash, ellipsis, curly quotes, section sign)

Usage:
  md2confluence-v3.py create --space <SPACE> --parent-page-id <ID> --title "..." \
      --markdown <path.md> [--attachments-dir <dir>] [--mermaid-fallback] [--dry-run]

  md2confluence-v3.py update --page-id <ID> --title "..." \
      --markdown <path.md> [--attachments-dir <dir>] [--mermaid-fallback] [--dry-run]

Authentication:
  CONFLUENCE_BASE_URL  - base URL, e.g. https://confluence.example.com
  CONFLUENCE_USER      - user (informational; bearer token auth is used)
  Token is resolved from ~/.claude/lib/credential-store.sh using the logical key
  found in ~/.claude/multi-agent-preferences.json .global.keychainMapping.confluence
  If that path fails, env CONFLUENCE_TOKEN is used as a fallback.

Output:
  JSON envelope on stdout describing what happened (page id, url, attachments,
  fallbacks used, warnings, parsed front-matter).
"""

from __future__ import annotations

import argparse
import html
import io
import json
import mimetypes
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable


# ---------------------------------------------------------------------------
# Punctuation gate
# ---------------------------------------------------------------------------

PUNCT_MAP_DEFAULT_TR = {
    "\u2014": " - ",   # em-dash codepoint U+2014
    "\u2013": "-",     # en-dash codepoint U+2013
    "\u2026": "...",   # horizontal ellipsis codepoint U+2026
    "\u201C": '"',     # curly double quote left codepoint U+201C
    "\u201D": '"',     # curly double quote right codepoint U+201D
    "\u2018": "'",     # curly single quote left codepoint U+2018
    "\u2019": "'",     # curly single quote right codepoint U+2019
    "\u00A7": "bolum", # section sign codepoint U+00A7 (TR fallback, plain ASCII)
}

PUNCT_MAP_EN = dict(PUNCT_MAP_DEFAULT_TR)
PUNCT_MAP_EN["\u00A7"] = "section"


def detect_language(markdown: str, front_matter_lang: str | None) -> str:
    if front_matter_lang:
        lang = front_matter_lang.lower()
        if lang.startswith("en"):
            return "en"
        return "tr"
    # Heuristic: presence of TR-only characters in headings
    for line in markdown.splitlines():
        if line.startswith("#"):
            if re.search(r"[ğışçöü]", line):
                return "tr"
    return "tr"


def apply_punctuation_gate(text: str, language: str) -> tuple[str, list[str]]:
    mapping = PUNCT_MAP_EN if language == "en" else PUNCT_MAP_DEFAULT_TR
    out = text
    for src, dst in mapping.items():
        out = out.replace(src, dst)

    warnings: list[str] = []
    leftover = [ch for ch in mapping.keys() if ch in out]
    if leftover:
        warnings.append(
            "punctuation gate: residual forbidden characters: "
            + " ".join(f"U+{ord(c):04X}" for c in leftover)
        )
    return out, warnings


# ---------------------------------------------------------------------------
# YAML front-matter (tiny subset parser - flat key: value pairs only)
# ---------------------------------------------------------------------------

FRONT_MATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)


def parse_front_matter(text: str) -> tuple[dict[str, str], str]:
    m = FRONT_MATTER_RE.match(text)
    if not m:
        return {}, text
    block = m.group(1)
    body = text[m.end():]
    data: dict[str, str] = {}
    for raw in block.splitlines():
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        if ":" not in raw:
            continue
        key, _, value = raw.partition(":")
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        if key:
            data[key] = value
    return data, body


# ---------------------------------------------------------------------------
# Mermaid blocks
# ---------------------------------------------------------------------------

MERMAID_FENCE_OPEN_RE = re.compile(r"^```mermaid\s*$")


def mermaid_macro(body: str) -> str:
    return (
        '<ac:structured-macro ac:name="mermaid">'
        f'<ac:plain-text-body><![CDATA[\n{cdata_escape(body)}\n]]></ac:plain-text-body>'
        '</ac:structured-macro>'
    )


def mermaid_to_numbered_list(body: str) -> str:
    """Best-effort flowchart node extraction for fallback rendering."""
    nodes: list[str] = []
    edges: list[tuple[str, str]] = []
    node_label: dict[str, str] = {}
    node_pattern = re.compile(r"([A-Za-z0-9_]+)\s*(?:\[([^\]]+)\]|\(([^)]+)\)|\{([^}]+)\})")
    edge_pattern = re.compile(r"([A-Za-z0-9_]+)\s*-->\s*([A-Za-z0-9_]+)")

    for raw in body.splitlines():
        line = raw.strip()
        if not line or line.startswith("%%"):
            continue
        for em in edge_pattern.finditer(line):
            a, b = em.group(1), em.group(2)
            edges.append((a, b))
            for nm in (a, b):
                if nm not in node_label:
                    node_label[nm] = nm
                    nodes.append(nm)
        for nm in node_pattern.finditer(line):
            key = nm.group(1)
            label = nm.group(2) or nm.group(3) or nm.group(4) or key
            if key not in node_label:
                node_label[key] = label
                nodes.append(key)
            else:
                node_label[key] = label

    if not nodes:
        # Could not parse; emit as plain pre block
        return f"<pre>{html.escape(body)}</pre>"

    next_map: dict[str, list[str]] = {}
    for a, b in edges:
        next_map.setdefault(a, []).append(b)

    items: list[str] = []
    for idx, key in enumerate(nodes, start=1):
        label = node_label[key]
        nxt = next_map.get(key, [])
        suffix = ""
        if nxt:
            suffix = " next: " + ", ".join(node_label.get(n, n) for n in nxt)
        items.append(f"<li>{html.escape(label)}{html.escape(suffix)}</li>")
    return "<ol>" + "".join(items) + "</ol>"


# ---------------------------------------------------------------------------
# Inline conversions
# ---------------------------------------------------------------------------

INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
BOLD_RE = re.compile(r"\*\*([^*\n]+)\*\*")
ITALIC_RE = re.compile(r"(?<![\*\w])\*([^*\n]+)\*(?![\*\w])")
FOOTNOTE_RE = re.compile(r"\^\[([^\]]+)\]")


def esc(s: str) -> str:
    return html.escape(s, quote=False)


def cdata_escape(s: str) -> str:
    """Make `s` safe to embed inside a CDATA section.

    A code fence or Mermaid diagram can legitimately contain the literal
    three-character sequence "]]>" (a real XML CDATA example, a shell case
    pattern, ...), which is the CDATA terminator - embedding it unescaped
    closes the section early and leaves the rest of the storage-format XML
    unparsed as markup. Splitting it across a close/reopen pair is the
    standard CDATA-in-CDATA escape: it renders identically, but is no longer
    a literal terminator to the XML parser.
    """
    return s.replace("]]>", "]]]]><![CDATA[>")


@dataclass
class InlineContext:
    attachments_available: set[str]
    tooltip_macro_supported: bool
    warnings: list[str] = field(default_factory=list)
    tooltip_fallback_used: bool = False
    images_referenced: list[str] = field(default_factory=list)


def render_image(alt: str, src: str, ctx: InlineContext) -> str:
    filename = os.path.basename(src)
    # If src is an http(s) URL, render as external image link.
    if re.match(r"^https?://", src):
        return f'<a href="{html.escape(src, quote=True)}">{esc(alt) or esc(filename)}</a>'
    ctx.images_referenced.append(filename)
    if ctx.attachments_available and filename not in ctx.attachments_available:
        ctx.warnings.append(f"image reference has no matching attachment: {filename}")
        return f'<a href="{html.escape(src, quote=True)}">{esc(alt) or esc(filename)}</a>'
    alt_attr = f' ac:alt="{html.escape(alt, quote=True)}"' if alt else ""
    return (
        f"<ac:image{alt_attr}>"
        f'<ri:attachment ri:filename="{html.escape(filename, quote=True)}" />'
        "</ac:image>"
    )


def render_footnote(text: str, ctx: InlineContext) -> str:
    if ctx.tooltip_macro_supported:
        return (
            '<ac:structured-macro ac:name="tooltip">'
            f'<ac:parameter ac:name="text">{esc(text)}</ac:parameter>'
            '</ac:structured-macro>'
        )
    ctx.tooltip_fallback_used = True
    return f" <em>({esc(text)})</em>"


def inline(s: str, ctx: InlineContext) -> str:
    # 1. Stash images first so their alt text is not treated as link.
    image_segs: list[str] = []

    def stash_image(m: re.Match[str]) -> str:
        alt, src = m.group(1), m.group(2)
        image_segs.append(render_image(alt, src, ctx))
        return f"\x00IMG{len(image_segs) - 1}\x00"

    s = IMAGE_RE.sub(stash_image, s)

    # 2. Inline code.
    code_segs: list[str] = []

    def stash_code(m: re.Match[str]) -> str:
        code_segs.append(m.group(1))
        return f"\x00CODE{len(code_segs) - 1}\x00"

    s = INLINE_CODE_RE.sub(stash_code, s)

    # 3. Links.
    link_segs: list[tuple[str, str]] = []

    def stash_link(m: re.Match[str]) -> str:
        link_segs.append((m.group(1), m.group(2)))
        return f"\x00LINK{len(link_segs) - 1}\x00"

    s = LINK_RE.sub(stash_link, s)

    # 4. Footnotes (must run before HTML escape so the brackets survive).
    footnote_segs: list[str] = []

    def stash_footnote(m: re.Match[str]) -> str:
        footnote_segs.append(render_footnote(m.group(1), ctx))
        return f"\x00FN{len(footnote_segs) - 1}\x00"

    s = FOOTNOTE_RE.sub(stash_footnote, s)

    # 5. Escape, then bold/italic.
    s = esc(s)
    s = BOLD_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", s)
    s = ITALIC_RE.sub(lambda m: f"<em>{m.group(1)}</em>", s)

    # 6. Restore stash markers.
    def restore_link(m: re.Match[str]) -> str:
        idx = int(m.group(1))
        label, url = link_segs[idx]
        return f'<a href="{html.escape(url, quote=True)}">{esc(label)}</a>'

    s = re.sub(r"\x00LINK(\d+)\x00", restore_link, s)
    s = re.sub(r"\x00CODE(\d+)\x00", lambda m: f"<code>{esc(code_segs[int(m.group(1))])}</code>", s)
    s = re.sub(r"\x00FN(\d+)\x00", lambda m: footnote_segs[int(m.group(1))], s)
    s = re.sub(r"\x00IMG(\d+)\x00", lambda m: image_segs[int(m.group(1))], s)
    return s


# ---------------------------------------------------------------------------
# Block-level conversion
# ---------------------------------------------------------------------------

def is_table_sep(line: str) -> bool:
    s = line.strip()
    if not s.startswith("|"):
        return False
    inner = s.strip("|").strip()
    return all(re.match(r":?-{3,}:?", c.strip()) for c in inner.split("|"))


@dataclass
class ConvertResult:
    storage_xml: str
    mermaid_fallback_used: bool
    tooltip_fallback_used: bool
    images_referenced: list[str]
    warnings: list[str]


def markdown_to_storage(
    markdown: str,
    *,
    attachments_available: set[str],
    mermaid_fallback: bool,
    tooltip_macro_supported: bool,
) -> ConvertResult:
    ctx = InlineContext(
        attachments_available=attachments_available,
        tooltip_macro_supported=tooltip_macro_supported,
    )
    lines = markdown.split("\n")
    out: list[str] = []
    i = 0
    in_code = False
    code_buf: list[str] = []
    code_lang = ""
    in_mermaid = False
    mermaid_buf: list[str] = []
    para_buf: list[str] = []
    mermaid_fallback_used = False

    def flush_para() -> None:
        if not para_buf:
            return
        text = " ".join(para_buf).strip()
        if text:
            out.append(f"<p>{inline(text, ctx)}</p>")
        para_buf.clear()

    while i < len(lines):
        line = lines[i]
        stripped = line.strip()

        # Mermaid fence
        if MERMAID_FENCE_OPEN_RE.match(stripped):
            flush_para()
            in_mermaid = True
            mermaid_buf = []
            i += 1
            continue
        if in_mermaid:
            if stripped == "```":
                body = "\n".join(mermaid_buf)
                if mermaid_fallback:
                    mermaid_fallback_used = True
                    out.append(mermaid_to_numbered_list(body))
                else:
                    out.append(mermaid_macro(body))
                in_mermaid = False
                mermaid_buf = []
            else:
                mermaid_buf.append(line)
            i += 1
            continue

        # Generic code fence
        if stripped.startswith("```"):
            if not in_code:
                in_code = True
                code_lang = stripped[3:].strip() or "none"
                code_buf = []
            else:
                in_code = False
                body = "\n".join(code_buf)
                flush_para()
                out.append(
                    '<ac:structured-macro ac:name="code">'
                    f'<ac:parameter ac:name="language">{esc(code_lang)}</ac:parameter>'
                    f'<ac:plain-text-body><![CDATA[{cdata_escape(body)}]]></ac:plain-text-body>'
                    '</ac:structured-macro>'
                )
            i += 1
            continue
        if in_code:
            code_buf.append(line)
            i += 1
            continue

        # Horizontal rule
        if stripped in ("---", "***", "___"):
            flush_para()
            out.append("<hr />")
            i += 1
            continue

        # Headings
        h = re.match(r"^(#{1,6})\s+(.*)$", stripped)
        if h:
            flush_para()
            level = len(h.group(1))
            out.append(f"<h{level}>{inline(h.group(2), ctx)}</h{level}>")
            i += 1
            continue

        # Blockquote
        if stripped.startswith("> "):
            flush_para()
            bq: list[str] = []
            while i < len(lines) and lines[i].strip().startswith("> "):
                bq.append(lines[i].strip()[2:])
                i += 1
            out.append(f"<blockquote><p>{inline(' '.join(bq), ctx)}</p></blockquote>")
            continue

        # Tables
        if stripped.startswith("|") and i + 1 < len(lines) and is_table_sep(lines[i + 1]):
            flush_para()
            header = [c.strip() for c in stripped.strip("|").split("|")]
            i += 2
            rows: list[list[str]] = []
            while i < len(lines) and lines[i].strip().startswith("|"):
                rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
                i += 1
            thead = "<thead><tr>" + "".join(f"<th>{inline(c, ctx)}</th>" for c in header) + "</tr></thead>"
            tbody_rows = "".join(
                "<tr>" + "".join(f"<td>{inline(c, ctx)}</td>" for c in r) + "</tr>" for r in rows
            )
            tbody = f"<tbody>{tbody_rows}</tbody>"
            out.append(f"<table>{thead}{tbody}</table>")
            continue

        # Unordered list
        if re.match(r"^\s*[-*]\s+", line):
            flush_para()
            items: list[str] = []
            while i < len(lines) and re.match(r"^\s*[-*]\s+", lines[i]):
                items.append(re.sub(r"^\s*[-*]\s+", "", lines[i]))
                i += 1
            body = "".join(f"<li>{inline(it, ctx)}</li>" for it in items)
            out.append(f"<ul>{body}</ul>")
            continue

        # Ordered list
        if re.match(r"^\s*\d+\.\s+", line):
            flush_para()
            items = []
            while i < len(lines) and re.match(r"^\s*\d+\.\s+", lines[i]):
                items.append(re.sub(r"^\s*\d+\.\s+", "", lines[i]))
                i += 1
            body = "".join(f"<li>{inline(it, ctx)}</li>" for it in items)
            out.append(f"<ol>{body}</ol>")
            continue

        if stripped == "":
            flush_para()
        else:
            para_buf.append(line)
        i += 1

    flush_para()
    return ConvertResult(
        storage_xml="".join(out),
        mermaid_fallback_used=mermaid_fallback_used,
        tooltip_fallback_used=ctx.tooltip_fallback_used,
        images_referenced=ctx.images_referenced,
        warnings=ctx.warnings,
    )


# ---------------------------------------------------------------------------
# Confluence HTTP helpers
# ---------------------------------------------------------------------------

@dataclass
class ConfluenceAuth:
    base_url: str
    token: str
    user: str | None = None


def resolve_token() -> str | None:
    prefs_path = Path.home() / ".claude" / "multi-agent-preferences.json"
    cred_script = Path.home() / ".claude" / "lib" / "credential-store.sh"
    if prefs_path.exists() and cred_script.exists():
        try:
            with prefs_path.open(encoding="utf-8") as fh:
                prefs = json.load(fh)
            logical = (
                prefs.get("global", {})
                .get("keychainMapping", {})
                .get("confluence")
            )
            if logical:
                proc = subprocess.run(
                    [str(cred_script), "get", str(logical)],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
                token = (proc.stdout or "").strip()
                if proc.returncode == 0 and token:
                    return token
        except (OSError, json.JSONDecodeError, subprocess.SubprocessError):
            pass
    return os.environ.get("CONFLUENCE_TOKEN") or None


def resolve_auth() -> ConfluenceAuth:
    base = os.environ.get("CONFLUENCE_BASE_URL", "").rstrip("/")
    if not base:
        sys.stderr.write("error: CONFLUENCE_BASE_URL is not set\n")
        sys.exit(2)
    token = resolve_token()
    if not token:
        sys.stderr.write(
            "error: no Confluence token available (credential-store + "
            "CONFLUENCE_TOKEN both empty)\n"
        )
        sys.exit(2)
    return ConfluenceAuth(base_url=base, token=token, user=os.environ.get("CONFLUENCE_USER"))


def _retry_http(fn: Callable[..., Any], *args: Any, max_attempts: int = 3, **kwargs: Any) -> Any:
    """Generic retry wrapper for HTTP calls.

    Retries on 5xx, 429, and transport URLError. Backoff: 1s, 2s, 4s.
    HTTPError with 4xx status codes (other than 429) propagates immediately so
    Confluence-level error semantics (e.g. mermaid macro missing) remain
    actionable at the call site.
    """
    last_exc: Exception | None = None
    for attempt in range(max_attempts):
        try:
            return fn(*args, **kwargs)
        except urllib.error.HTTPError as e:
            if e.code >= 500 or e.code == 429:
                if attempt < max_attempts - 1:
                    time.sleep(2 ** attempt)
                    last_exc = e
                    continue
            raise
        except urllib.error.URLError as e:
            if attempt < max_attempts - 1:
                time.sleep(2 ** attempt)
                last_exc = e
                continue
            raise
    if last_exc:
        raise last_exc
    raise RuntimeError("_retry_http: unreachable")


def http_json(
    auth: ConfluenceAuth,
    method: str,
    path: str,
    payload: dict | list | None = None,
) -> tuple[int, dict | list | None, str]:
    url = f"{auth.base_url}{path}"
    data = json.dumps(payload).encode("utf-8") if payload is not None else None
    req = urllib.request.Request(
        url,
        data=data,
        method=method,
        headers={
            "Authorization": f"Bearer {auth.token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
    )

    def _do() -> tuple[int, dict | list | None, str]:
        with urllib.request.urlopen(req) as resp:
            body = resp.read().decode("utf-8")
            return resp.status, (json.loads(body) if body else None), body

    try:
        return _retry_http(_do)
    except urllib.error.HTTPError as e:
        body = ""
        try:
            body = e.read().decode("utf-8", errors="replace")
        except Exception:
            pass
        return e.code, None, body
    except urllib.error.URLError as e:
        # All retries exhausted; surface as a synthetic 0 status so callers
        # treat it as a hard failure without crashing.
        return 0, None, f"URLError: {e.reason}"


def build_multipart(files: list[tuple[str, bytes, str]]) -> tuple[bytes, str]:
    boundary = f"----md2confluence-{uuid.uuid4().hex}"
    buf = io.BytesIO()
    for field_name, content, filename in files:
        ctype, _ = mimetypes.guess_type(filename)
        ctype = ctype or "application/octet-stream"
        buf.write(f"--{boundary}\r\n".encode("utf-8"))
        buf.write(
            (
                f'Content-Disposition: form-data; name="{field_name}"; '
                f'filename="{filename}"\r\n'
            ).encode("utf-8")
        )
        buf.write(f"Content-Type: {ctype}\r\n\r\n".encode("utf-8"))
        buf.write(content)
        buf.write(b"\r\n")
    buf.write(f"--{boundary}--\r\n".encode("utf-8"))
    return buf.getvalue(), f"multipart/form-data; boundary={boundary}"


def upload_attachment(
    auth: ConfluenceAuth,
    page_id: str,
    file_path: Path,
) -> tuple[bool, str]:
    """Attach file to page. Uses POST .../child/attachment, which overwrites if
    Confluence is configured to accept the same filename. Wrapped in retry so
    transient 5xx/429/network errors recover before the call site sees them."""
    data = file_path.read_bytes()
    body, content_type = build_multipart([("file", data, file_path.name)])
    url = f"{auth.base_url}/rest/api/content/{page_id}/child/attachment"
    req = urllib.request.Request(
        url,
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {auth.token}",
            "Content-Type": content_type,
            "X-Atlassian-Token": "no-check",
            "Accept": "application/json",
        },
    )

    def _do() -> tuple[bool, str]:
        with urllib.request.urlopen(req) as resp:
            resp.read()
            return True, "ok"

    try:
        return _retry_http(_do)
    except urllib.error.HTTPError as e:
        # If attachment already exists, retry against the existing attachment id.
        if e.code == 400:
            err_body = ""
            try:
                err_body = e.read().decode("utf-8", errors="replace")
            except Exception:
                pass
            existing = find_existing_attachment(auth, page_id, file_path.name)
            if existing:
                ok, msg = update_attachment_data(auth, page_id, existing, file_path)
                return ok, msg
            return False, f"HTTP 400: {err_body[:300]}"
        return False, f"HTTP {e.code}"
    except urllib.error.URLError as e:
        return False, f"URLError: {e.reason}"


def upload_attachments_parallel(
    auth: ConfluenceAuth,
    page_id: str,
    attachments: dict[str, Path],
    max_workers: int = 4,
) -> tuple[int, list[str]]:
    """Upload many attachments concurrently via a small thread pool.

    Returns (success_count, warning_messages). Sequential ordering is not
    preserved (the prior implementation processed `sorted(referenced)`), but
    that ordering had no semantic meaning - it was just deterministic for
    debugging. The thread pool keeps per-task retry semantics intact via
    upload_attachment().
    """
    if not attachments:
        return 0, []
    warnings: list[str] = []
    uploaded = 0
    workers = max(1, min(max_workers, len(attachments)))
    with ThreadPoolExecutor(max_workers=workers) as executor:
        future_to_name = {
            executor.submit(upload_attachment, auth, page_id, file_path): name
            for name, file_path in attachments.items()
        }
        for future in as_completed(future_to_name):
            name = future_to_name[future]
            try:
                ok, msg = future.result()
                if ok:
                    uploaded += 1
                else:
                    warnings.append(f"attachment upload failed for {name}: {msg}")
            except Exception as e:
                warnings.append(f"attachment upload exception for {name}: {e}")
    return uploaded, warnings


def find_existing_attachment(
    auth: ConfluenceAuth, page_id: str, filename: str
) -> str | None:
    status, body, _ = http_json(
        auth,
        "GET",
        f"/rest/api/content/{page_id}/child/attachment?filename="
        + urllib.parse.quote(filename),
    )
    if status == 200 and isinstance(body, dict):
        results = body.get("results") or []
        if results:
            return results[0].get("id")
    return None


def update_attachment_data(
    auth: ConfluenceAuth,
    page_id: str,
    attachment_id: str,
    file_path: Path,
) -> tuple[bool, str]:
    data = file_path.read_bytes()
    body, content_type = build_multipart([("file", data, file_path.name)])
    url = (
        f"{auth.base_url}/rest/api/content/{page_id}/child/attachment/"
        f"{attachment_id}/data"
    )
    req = urllib.request.Request(
        url,
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {auth.token}",
            "Content-Type": content_type,
            "X-Atlassian-Token": "no-check",
            "Accept": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req) as resp:
            resp.read()
            return True, "updated"
    except urllib.error.HTTPError as e:
        return False, f"HTTP {e.code}"


def add_labels(auth: ConfluenceAuth, page_id: str, labels: list[str]) -> list[str]:
    if not labels:
        return []
    payload = [{"prefix": "global", "name": label} for label in labels]
    status, _, body = http_json(
        auth, "POST", f"/rest/api/content/{page_id}/label", payload
    )
    if status not in (200, 201, 204):
        return [f"label POST returned {status}: {body[:200]}"]
    return []


def page_web_url(auth: ConfluenceAuth, page: dict | None, page_id: str) -> str:
    if isinstance(page, dict):
        web = page.get("_links", {}).get("webui")
        if web:
            return f"{auth.base_url}{web}"
    return f"{auth.base_url}/pages/viewpage.action?pageId={page_id}"


# ---------------------------------------------------------------------------
# Attachment discovery
# ---------------------------------------------------------------------------

def collect_attachments(attachments_dir: Path | None) -> dict[str, Path]:
    if not attachments_dir:
        return {}
    if not attachments_dir.is_dir():
        return {}
    found: dict[str, Path] = {}
    for path in sorted(attachments_dir.iterdir()):
        if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}:
            found[path.name] = path
    return found


# ---------------------------------------------------------------------------
# Mermaid macro detection (post-publish error handling)
# ---------------------------------------------------------------------------

def looks_like_macro_missing(error_body: str, macro_name: str) -> bool:
    needle = error_body.lower()
    return (
        macro_name in needle
        and (
            "unknown macro" in needle
            or "macro not found" in needle
            or "no such macro" in needle
        )
    )


# ---------------------------------------------------------------------------
# Build a publish-ready storage XML pipeline
# ---------------------------------------------------------------------------

def build_storage(
    markdown_text: str,
    *,
    attachments_dir: Path | None,
    mermaid_fallback: bool,
    tooltip_macro_supported: bool = True,
) -> tuple[ConvertResult, dict[str, str], dict[str, Path], list[str]]:
    front_matter, body = parse_front_matter(markdown_text)
    language = detect_language(body, front_matter.get("language"))
    body, punct_warnings = apply_punctuation_gate(body, language)
    attachments = collect_attachments(attachments_dir)
    result = markdown_to_storage(
        body,
        attachments_available=set(attachments.keys()),
        mermaid_fallback=mermaid_fallback,
        tooltip_macro_supported=tooltip_macro_supported,
    )
    result.warnings = punct_warnings + result.warnings
    return result, front_matter, attachments, []


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_create(args: argparse.Namespace) -> int:
    md_text = Path(args.markdown).read_text(encoding="utf-8")
    attachments_dir = Path(args.attachments_dir) if args.attachments_dir else None
    result, front_matter, attachments, _ = build_storage(
        md_text,
        attachments_dir=attachments_dir,
        mermaid_fallback=args.mermaid_fallback,
    )

    if args.dry_run:
        sys.stdout.write(result.storage_xml + "\n")
        envelope = {
            "front_matter": front_matter,
            "page_id": None,
            "page_url": None,
            "attachments_uploaded": 0,
            "attachments_available": len(attachments),
            "mermaid_macro_fallback_used": result.mermaid_fallback_used,
            "tooltip_macro_fallback_used": result.tooltip_fallback_used,
            "warnings": result.warnings,
            "dry_run": True,
        }
        sys.stderr.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
        return 0

    auth = resolve_auth()
    warnings = list(result.warnings)

    # 1. Create the page with current storage body.
    create_payload = {
        "type": "page",
        "title": args.title,
        "ancestors": [{"id": args.parent_page_id}],
        "space": {"key": args.space},
        "body": {"storage": {"value": result.storage_xml, "representation": "storage"}},
    }
    status, body, raw = http_json(auth, "POST", "/rest/api/content", create_payload)

    # Retry with mermaid fallback if the macro isn't installed.
    if status >= 400 and not args.mermaid_fallback and looks_like_macro_missing(raw, "mermaid"):
        warnings.append("mermaid macro missing; retrying with numbered-list fallback")
        result, front_matter, attachments, _ = build_storage(
            md_text,
            attachments_dir=attachments_dir,
            mermaid_fallback=True,
        )
        create_payload["body"]["storage"]["value"] = result.storage_xml
        status, body, raw = http_json(auth, "POST", "/rest/api/content", create_payload)

    if status >= 400 or not isinstance(body, dict):
        sys.stderr.write(f"create failed: HTTP {status}\n{raw[:600]}\n")
        return 2
    page_id = str(body.get("id"))
    page_url = page_web_url(auth, body, page_id)

    # 2. Upload attachments referenced by markdown - parallel thread pool.
    uploaded = 0
    if attachments and result.images_referenced:
        to_upload = {
            name: attachments[name]
            for name in result.images_referenced
            if name in attachments
        }
        if to_upload:
            uploaded, upload_warnings = upload_attachments_parallel(auth, page_id, to_upload)
            warnings.extend(upload_warnings)

    # 3. Apply labels from front matter.
    labels = build_labels(front_matter)
    label_errors = add_labels(auth, page_id, labels)
    warnings.extend(label_errors)

    envelope = {
        "front_matter": front_matter,
        "page_id": page_id,
        "page_url": page_url,
        "attachments_uploaded": uploaded,
        "attachments_available": len(attachments),
        "mermaid_macro_fallback_used": result.mermaid_fallback_used,
        "tooltip_macro_fallback_used": result.tooltip_fallback_used,
        "warnings": warnings,
    }
    sys.stdout.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
    return 0


def cmd_update(args: argparse.Namespace) -> int:
    md_text = Path(args.markdown).read_text(encoding="utf-8")
    attachments_dir = Path(args.attachments_dir) if args.attachments_dir else None
    result, front_matter, attachments, _ = build_storage(
        md_text,
        attachments_dir=attachments_dir,
        mermaid_fallback=args.mermaid_fallback,
    )

    if args.dry_run:
        sys.stdout.write(result.storage_xml + "\n")
        envelope = {
            "front_matter": front_matter,
            "page_id": args.page_id,
            "page_url": None,
            "attachments_uploaded": 0,
            "attachments_available": len(attachments),
            "mermaid_macro_fallback_used": result.mermaid_fallback_used,
            "tooltip_macro_fallback_used": result.tooltip_fallback_used,
            "warnings": result.warnings,
            "dry_run": True,
        }
        sys.stderr.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
        return 0

    auth = resolve_auth()
    warnings = list(result.warnings)

    # Fetch current version.
    status, body, raw = http_json(
        auth, "GET", f"/rest/api/content/{args.page_id}?expand=version"
    )
    if status >= 400 or not isinstance(body, dict):
        sys.stderr.write(f"fetch failed: HTTP {status}\n{raw[:600]}\n")
        return 2
    current_version = int(body.get("version", {}).get("number", 1))
    new_version = current_version + 1

    update_payload = {
        "id": args.page_id,
        "type": "page",
        "title": args.title,
        "version": {"number": new_version},
        "body": {"storage": {"value": result.storage_xml, "representation": "storage"}},
    }
    status, body, raw = http_json(
        auth, "PUT", f"/rest/api/content/{args.page_id}", update_payload
    )

    if status >= 400 and not args.mermaid_fallback and looks_like_macro_missing(raw, "mermaid"):
        warnings.append("mermaid macro missing; retrying with numbered-list fallback")
        result, front_matter, attachments, _ = build_storage(
            md_text,
            attachments_dir=attachments_dir,
            mermaid_fallback=True,
        )
        update_payload["body"]["storage"]["value"] = result.storage_xml
        status, body, raw = http_json(
            auth, "PUT", f"/rest/api/content/{args.page_id}", update_payload
        )

    if status >= 400 or not isinstance(body, dict):
        sys.stderr.write(f"update failed: HTTP {status}\n{raw[:600]}\n")
        return 2

    page_id = str(body.get("id") or args.page_id)
    page_url = page_web_url(auth, body, page_id)

    uploaded = 0
    if attachments and result.images_referenced:
        to_upload = {
            name: attachments[name]
            for name in result.images_referenced
            if name in attachments
        }
        if to_upload:
            uploaded, upload_warnings = upload_attachments_parallel(auth, page_id, to_upload)
            warnings.extend(upload_warnings)

    labels = build_labels(front_matter)
    label_errors = add_labels(auth, page_id, labels)
    warnings.extend(label_errors)

    envelope = {
        "front_matter": front_matter,
        "page_id": page_id,
        "page_url": page_url,
        "attachments_uploaded": uploaded,
        "attachments_available": len(attachments),
        "mermaid_macro_fallback_used": result.mermaid_fallback_used,
        "tooltip_macro_fallback_used": result.tooltip_fallback_used,
        "warnings": warnings,
    }
    sys.stdout.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
    return 0


def build_labels(front_matter: dict[str, str]) -> list[str]:
    labels: list[str] = []
    for key in ("feature", "platform", "evidence_digest"):
        value = front_matter.get(key)
        if not value:
            continue
        normalized = re.sub(r"[^A-Za-z0-9_-]", "-", value)[:60].strip("-").lower()
        if normalized:
            labels.append(f"{key}-{normalized}")
    return labels


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="md2confluence-v3.py",
        description=(
            "Convert markdown to Confluence storage XML and create or update a page. "
            "Supports attachment upload, mermaid macro, tooltip macro, and YAML "
            "front-matter labels."
        ),
    )
    sub = parser.add_subparsers(dest="command", required=True)

    create = sub.add_parser("create", help="Create a new Confluence page")
    create.add_argument("--space", required=True)
    create.add_argument("--parent-page-id", required=True)
    create.add_argument("--title", required=True)
    create.add_argument("--markdown", required=True)
    create.add_argument("--attachments-dir")
    create.add_argument("--mermaid-fallback", action="store_true")
    create.add_argument("--dry-run", action="store_true")
    create.set_defaults(func=cmd_create)

    update = sub.add_parser("update", help="Update an existing Confluence page")
    update.add_argument("--page-id", required=True)
    update.add_argument("--title", required=True)
    update.add_argument("--markdown", required=True)
    update.add_argument("--attachments-dir")
    update.add_argument("--mermaid-fallback", action="store_true")
    update.add_argument("--dry-run", action="store_true")
    update.set_defaults(func=cmd_update)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
