#!/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"

# Typographic dashes (U+2010 to U+2015), quotes (U+2018 to U+201F), the
# ellipsis and the section sign, including the variants the maps above do
# not rewrite, so a residual can actually be reported.
FORBIDDEN_PUNCT_RE = re.compile("[\u2010-\u2015\u2018-\u201F\u2026\u00A7]")


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: a Turkish document carries Turkish-only letters somewhere in
    # its body; a text without a single one is English.
    if re.search(r"[ğışçöüĞİŞÇÖÜ]", markdown):
        return "tr"
    return "en"


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] = []
    # The residual check must look wider than the mapping: every mapped
    # character was just replaced, so testing those again can never fire.
    leftover = sorted(set(FORBIDDEN_PUNCT_RE.findall(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 code_macro(lang: str, body: str) -> str:
    """A plain code macro, used to carry mermaid source next to a lossy fallback."""
    return (
        '<ac:structured-macro ac:name="code">'
        f'<ac:parameter ac:name="language">{html.escape(lang, quote=True)}</ac:parameter>'
        f'<ac:plain-text-body><![CDATA[\n{cdata_escape(body)}\n]]></ac:plain-text-body>'
        '</ac:structured-macro>'
    )


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, str]] = []
    node_label: dict[str, str] = {}
    node_pattern = re.compile(r"([A-Za-z0-9_]+)\s*(?:\[([^\]]+)\]|\(([^)]+)\)|\{([^}]+)\})")
    # `A --> B` was the only shape matched, so every labelled branch
    # (`B -- Evet --> C`, `B -->|Hayir| D`) lost both its edge and its label, which
    # is the part of a flow chart that carries the decision. Both label syntaxes are
    # captured now and the label is kept.
    # A node id is usually followed by its shape and label - A[Basla], B{Karar} -
    # so the id is not adjacent to the arrow. The old pattern required adjacency and
    # therefore matched no edge at all in any diagram whose nodes carry labels, which
    # is every real one. Skip an optional [..] (..) {..} on both sides.
    _SHAPE = r"(?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\})?"
    edge_pattern = re.compile(
        r"([A-Za-z0-9_]+)" + _SHAPE + r"\s*"
        r"(?:--\s*(?P<lbl_dash>[^->|]+?)\s*-->|-->\s*\|(?P<lbl_pipe>[^|]*)\||-->)"
        r"\s*([A-Za-z0-9_]+)" + _SHAPE
    )

    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(4)
            label = (em.group("lbl_dash") or em.group("lbl_pipe") or "").strip()
            edges.append((a, b, label))
            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[tuple[str, str]]] = {}
    for a, b, lbl in edges:
        next_map.setdefault(a, []).append((b, lbl))

    items: list[str] = []
    for idx, key in enumerate(nodes, start=1):
        label = node_label[key]
        nxt = next_map.get(key, [])
        suffix = ""
        if nxt:
            # "next: Evet -> Odeme, Hayir -> Hata" keeps the branch condition, which
            # is the only reason a reader looks at a decision node.
            parts = [
                (f"{lbl} -> {node_label.get(n, n)}" if lbl else node_label.get(n, n))
                for n, lbl in nxt
            ]
            suffix = " next: " + ", ".join(parts)
        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
    image_width: int = 0
    warnings: list[str] = field(default_factory=list)
    tooltip_fallback_used: bool = False
    images_referenced: list[str] = field(default_factory=list)


# A 2x export is 750x1624 for a phone frame and 2880x2048 for a desktop one, and
# Confluence renders an <ac:image> at its natural size, so one screenshot took two
# screen heights on the page. Width is a display attribute only: the attachment
# stays full resolution and a click still opens the original.
# 720 is a desktop frame at half of a 2x export and a diagram at readable size;
# a phone frame overrides itself down to 320 through the title syntax, which the
# frame-gallery row writes because it is the only place that knows the frame width.
DEFAULT_IMAGE_WIDTH = 720

IMAGE_WIDTH_RE = re.compile(r"\bwidth\s*=\s*(\d{2,4})\b")


def render_image(alt: str, src: str, ctx: InlineContext) -> str:
    # Markdown title syntax carries the per-image override: ![alt](f.png "width=320")
    width = None
    m = re.match(r'^(?P<path>\S+)\s+"(?P<title>[^"]*)"$', src.strip())
    if m:
        src = m.group("path")
        w = IMAGE_WIDTH_RE.search(m.group("title"))
        if w:
            width = int(w.group(1))
    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>'
    if width is None:
        width = ctx.image_width
    alt_attr = f' ac:alt="{html.escape(alt, quote=True)}"' if alt else ""
    width_attr = f' ac:width="{width}"' if width else ""
    return (
        f"<ac:image{alt_attr}{width_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,
    image_width: int = 0,
) -> ConvertResult:
    ctx = InlineContext(
        attachments_available=attachments_available,
        tooltip_macro_supported=tooltip_macro_supported,
        image_width=image_width,
    )
    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
                    # The list is lossy however carefully it is built, so the source
                    # goes with it. A reader who needs the real diagram can paste it
                    # anywhere that renders mermaid; without it the diagram is gone.
                    out.append(mermaid_to_numbered_list(body))
                    out.append(code_macro("text", 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."""
    # Look before creating. Re-attaching a filename that is already on the page is
    # a duplicate, and which status a Confluence tells you that with is not a
    # constant: Cloud answers 400, this Server answers 500. Keying the
    # already-exists path off a status code meant the update path was unreachable
    # on Server, so every re-run logged one warning per attachment while the files
    # were in fact fine. Asking first removes the guess, and leaves the error path
    # as what it should have been all along: a race, not the normal case.
    existing = find_existing_attachment(auth, page_id, file_path.name)
    if existing:
        return update_attachment_data(auth, page_id, existing, file_path)

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

    try:
        return _retry_http(_do)
    except urllib.error.HTTPError as e:
        # Race: something attached the same filename between the check and here.
        # Both codes are treated the same because both mean the same thing.
        if e.code in (400, 409, 500):
            existing = find_existing_attachment(auth, page_id, file_path.name)
            if existing:
                return update_attachment_data(auth, page_id, existing, file_path)
            err_body = ""
            try:
                err_body = e.read().decode("utf-8", errors="replace")
            except Exception:
                pass
            return False, f"HTTP {e.code}: {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, int, list[str]]:
    """Upload many attachments concurrently via a small thread pool.

    Returns (created_count, updated_count, warning_messages). Created and updated
    are counted apart because they are not the same event: a re-run of the same
    document updates every attachment and creates none, and reporting that as
    "0 uploaded, 12 warnings" reads as data loss when nothing was lost.
    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, 0, []
    warnings: list[str] = []
    created = 0
    updated = 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:
                    if msg == "updated":
                        updated += 1
                    else:
                        created += 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 created, updated, 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,
    image_width: int = DEFAULT_IMAGE_WIDTH,
) -> 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,
        image_width=image_width,
    )
    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,
        image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
    )

    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_updated": 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,
            image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
        )
        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
    updated = 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, updated, 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_updated": updated,
        "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,
        image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
    )

    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_updated": 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,
            image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
        )
        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
    updated = 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, updated, 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_updated": updated,
        "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(
        "--image-width",
        type=int,
        default=DEFAULT_IMAGE_WIDTH,
        help="Display width in px for embedded attachment images (0 = natural size). "
        "Per-image override: ![alt](f.png \"width=320\").",
    )
    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(
        "--image-width",
        type=int,
        default=DEFAULT_IMAGE_WIDTH,
        help="Display width in px for embedded attachment images (0 = natural size).",
    )
    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())
