"""Top-level orchestrator for PPTX -> SVG conversion.

Public API: convert_pptx_to_svg(pptx_path, output_dir, options).

Composes the per-slide pipeline:
    OoxmlPackage -> shape_walker.walk_sp_tree
                 -> per-shape dispatch (prstgeom / txbody / pic / ...)
                 -> assembled SVG text + extracted media files

Stages B-F will fill in the per-shape dispatch. For Stage A this entry just
loads the package and reports basic per-slide structure to verify wiring.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field
from html import unescape
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit

from .color_resolver import ColorPalette
from .emu_units import NS
from .import_diagnostics import ImportDiagnostic, append_diagnostic
from .ooxml_loader import (
    OoxmlPackage,
    PartRef,
    SlideRef,
    part_show_master_sp,
)
from .slide_to_svg import assemble_part_solo, assemble_slide


_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
_MANAGED_PRIMARY_SVG_RE = re.compile(
    r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg"
)
_MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg")
_SVG_HREF_RE = re.compile(
    r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']"
)


def _validate_media_subdir(value: str) -> None:
    """Reject media output paths that can escape the conversion workspace."""
    path = Path(value)
    if path.drive or path.anchor or path.is_absolute() or ".." in path.parts:
        raise ValueError(
            f"media_subdir must stay within the output workspace: {value!r}"
        )


def _validate_media_filename(filename: str) -> None:
    """Require one media basename so asset maps cannot redirect writes."""
    path = Path(filename)
    if (
        not filename
        or filename in {".", ".."}
        or path.drive
        or path.anchor
        or path.name != filename
        or "/" in filename
        or "\\" in filename
    ):
        raise ValueError(f"Media filename must be a basename: {filename!r}")


def _extract_theme_info(
    theme: PartRef,
    palette: ColorPalette,
) -> tuple[dict[str, str], dict[str, str]]:
    from .color_resolver import find_color_elem, resolve_color

    colors: dict[str, str] = {}
    fonts: dict[str, str] = {}

    scheme = theme.xml.find(".//a:clrScheme", NS)
    if scheme is not None:
        for child in list(scheme):
            if not isinstance(child.tag, str):
                continue
            name = child.tag.split("}", 1)[-1]
            try:
                color_elem = find_color_elem(child)
                hex_, _ = resolve_color(color_elem, palette)
            except ValueError as exc:
                if palette.strict:
                    raise
                palette._diagnose(
                    "theme-summary-color-omitted",
                    str(exc),
                    "omit only this malformed theme-summary color",
                )
                continue
            if hex_:
                colors[name] = hex_

    font_scheme = theme.xml.find(".//a:fontScheme", NS)
    if font_scheme is not None:
        for slot in ("majorFont", "minorFont"):
            fnt = font_scheme.find(f"a:{slot}", NS)
            if fnt is None:
                continue
            role_prefix = "major" if slot == "majorFont" else "minor"
            latin = fnt.find("a:latin", NS)
            if latin is not None and latin.attrib.get("typeface"):
                fonts[f"{role_prefix}Latin"] = latin.attrib["typeface"]
            ea = fnt.find("a:ea", NS)
            if ea is not None and ea.attrib.get("typeface"):
                fonts[f"{role_prefix}EastAsia"] = ea.attrib["typeface"]
            cs = fnt.find("a:cs", NS)
            if cs is not None and cs.attrib.get("typeface"):
                fonts[f"{role_prefix}ComplexScript"] = cs.attrib["typeface"]
            for supplemental in fnt.findall("a:font", NS):
                script = supplemental.attrib.get("script", "")
                typeface = supplemental.attrib.get("typeface", "")
                if script in _CJK_THEME_SCRIPTS and typeface:
                    fonts[f"{role_prefix}Script{script}"] = typeface

    return colors, fonts


@dataclass
class ConvertOptions:
    """Convert behavior knobs.

    media_subdir: where to write media files relative to output_dir. SVG image
        href will use './<media_subdir>/<filename>'.
    embed_images: when True, base64-encode images inline instead of writing
        files. Default False (matches svg_to_pptx default of external images).
    keep_hidden: include shapes marked hidden="1". Default False.
    inheritance_mode: how to render master/layout shapes per slide SVG.
        - "both" (default): emit both views — layered under svg/ for template
          designers (master/layout/slide as separate files) and flat under
          svg-flat/ for previewers (each slide self-contained). Costs roughly
          1.3-1.5× converter time and ~1.6-2× disk vs. either single mode.
        - "layered": skip inherited shapes inside the slide. The orchestrator
          renders every master and layout to its own SVG, plus
          svg/inheritance.json describing the reuse graph. Optimised for
          template authors who need to see "what is shared vs. unique".
        - "flat": inline the inherited shapes visible under the source
          ``showMasterSp`` flags. Used by svg_to_pptx round-trip and any caller
          that wants self-contained slides (preview pages, screenshot pipelines).
    strict: stop on the first unsupported or malformed source construct.
        Default False keeps usable content and records structured diagnostics.
    """

    media_subdir: str = "assets"
    embed_images: bool = False
    keep_hidden: bool = False
    inheritance_mode: str = "both"
    asset_name_map: dict[str, str] = field(default_factory=dict)
    strict: bool = False


@dataclass
class PartArtifact:
    """Result of converting a master or layout part to SVG (layered mode only)."""

    role: str  # "master" | "layout"
    part_path: str  # OOXML part path, e.g. "ppt/slideLayouts/slideLayout3.xml"
    filename: str  # output svg filename, e.g. "layout_03_title.xml.svg"
    svg: str
    media_files: dict[str, bytes] = field(default_factory=dict)
    parent_master_part_path: str | None = None
    theme_part_path: str | None = None
    show_master_shapes: bool = True


@dataclass
class SlideArtifact:
    """Result of converting a single slide."""

    index: int  # 1-based
    svg: str
    media_files: dict[str, bytes] = field(default_factory=dict)
    layout_part_path: str | None = None
    master_part_path: str | None = None
    show_inherited_shapes: bool = True


@dataclass
class ConvertResult:
    """Result of converting an entire .pptx.

    ``slides`` holds the layered/primary view (or, in pure flat mode, the flat
    view). ``flat_slides`` is populated only in ``"both"`` mode and contains
    self-contained renderings of every slide; callers that don't care about
    the flat view can ignore it.
    """

    slides: list[SlideArtifact] = field(default_factory=list)
    canvas_px: tuple[float, float] = (1280.0, 720.0)
    theme_colors: dict[str, str] = field(default_factory=dict)
    theme_fonts: dict[str, str] = field(default_factory=dict)
    layouts: list[PartArtifact] = field(default_factory=list)
    masters: list[PartArtifact] = field(default_factory=list)
    flat_slides: list[SlideArtifact] = field(default_factory=list)
    master_themes: dict[str, dict[str, object]] = field(default_factory=dict)
    diagnostics: list[ImportDiagnostic] = field(default_factory=list)
    source_file: str = ""
    strict: bool = False


def _palette_diagnostic_sink(
    result: ConvertResult,
    *,
    part_path: str,
    slide_index: int | None = None,
) -> Callable[[str, str, str], None]:
    """Build a package-level diagnostic sink for palette initialization."""
    def _record(code: str, message: str, fallback: str) -> None:
        append_diagnostic(
            result.diagnostics,
            ImportDiagnostic(
                code=code,
                message=message,
                fallback=fallback,
                part_path=part_path,
                slide_index=slide_index,
            ),
        )

    return _record


def _make_palette(
    master: PartRef | None,
    theme: PartRef | None,
    options: ConvertOptions,
    result: ConvertResult,
    *,
    part_path: str,
    slide_index: int | None = None,
) -> ColorPalette:
    """Create one strict or tolerant palette with structured diagnostics."""
    return ColorPalette(
        master,
        theme,
        strict=options.strict,
        diagnostic_sink=_palette_diagnostic_sink(
            result,
            part_path=part_path,
            slide_index=slide_index,
        ),
    )


# ---------------------------------------------------------------------------
# Entry
# ---------------------------------------------------------------------------

def convert_pptx_to_svg(
    pptx_path: Path,
    output_dir: Path | None = None,
    options: ConvertOptions | None = None,
) -> ConvertResult:
    """Convert a .pptx file to one SVG per slide.

    Args:
        pptx_path: Source .pptx file.
        output_dir: When given, write svg/<slide_NN>.svg + media files there.
            When None, files are not written; callers can read SlideArtifact.svg.
        options: ConvertOptions; defaults to ConvertOptions().

    Returns:
        ConvertResult with per-slide SVG strings and resolved theme info.
    """
    options = options or ConvertOptions()
    if options.inheritance_mode not in {"flat", "layered", "both"}:
        raise ValueError(
            f"inheritance_mode must be 'flat', 'layered', or 'both', "
            f"got {options.inheritance_mode!r}"
        )
    if not options.embed_images:
        _validate_media_subdir(options.media_subdir)
    emit_layered = options.inheritance_mode in {"layered", "both"}
    emit_flat = options.inheritance_mode in {"flat", "both"}
    result = ConvertResult(
        source_file=pptx_path.name,
        strict=options.strict,
    )

    with OoxmlPackage(pptx_path) as pkg:
        result.canvas_px = pkg.slide_size_px

        # Default theme summary is kept for compatibility; conversion itself
        # resolves palette/fonts per slide master.
        first_slide = pkg.get_slide(1)
        default_master = first_slide.master if first_slide else None
        default_theme = pkg.resolve_theme(default_master)
        palette = _make_palette(
            default_master,
            default_theme,
            options,
            result,
            part_path=default_theme.path if default_theme is not None else "",
        )
        if default_theme is not None:
            result.theme_colors, result.theme_fonts = _extract_theme_info(default_theme, palette)

        for master in pkg.iter_all_masters():
            theme = pkg.resolve_theme(master) or default_theme
            pal = _make_palette(
                master,
                theme,
                options,
                result,
                part_path=master.path,
            )
            colors, fonts = _extract_theme_info(theme, pal) if theme is not None else ({}, {})
            result.master_themes[master.path] = {
                "themePath": theme.path if theme is not None else None,
                "colors": colors,
                "fonts": fonts,
            }

        # Per-slide conversion. The primary view is layered when emitted
        # (template designers care most about that one); the flat view is
        # rendered alongside when needed.
        primary_mode = "layered" if emit_layered else "flat"
        for slide in pkg.iter_slides():
            slide_theme = pkg.resolve_theme(slide.master) or default_theme
            slide_palette = _make_palette(
                slide.master,
                slide_theme,
                options,
                result,
                part_path=slide.part.path,
                slide_index=slide.index,
            )
            _colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
            artifact = _convert_slide(
                pkg,
                slide,
                slide_palette,
                options,
                result.diagnostics,
                slide_fonts,
                inheritance_mode=primary_mode,
            )
            result.slides.append(artifact)
        if emit_layered and emit_flat:
            for slide in pkg.iter_slides():
                slide_theme = pkg.resolve_theme(slide.master) or default_theme
                slide_palette = _make_palette(
                    slide.master,
                    slide_theme,
                    options,
                    result,
                    part_path=slide.part.path,
                    slide_index=slide.index,
                )
                _colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
                artifact = _convert_slide(
                    pkg,
                    slide,
                    slide_palette,
                    options,
                    result.diagnostics,
                    slide_fonts,
                    inheritance_mode="flat",
                )
                result.flat_slides.append(artifact)

        # Layered mode: also render each master / layout once.
        if emit_layered:
            _convert_inheritance_parts(pkg, default_theme, options, result)

    if output_dir is not None:
        _write_artifacts(output_dir, result, options)

    return result


def _convert_slide(
    pkg: OoxmlPackage,
    slide: SlideRef,
    palette: ColorPalette,
    options: ConvertOptions,
    diagnostics: list[ImportDiagnostic],
    theme_fonts: dict[str, str] | None = None,
    *,
    inheritance_mode: str | None = None,
) -> SlideArtifact:
    """Convert a single slide via the full shape pipeline.

    ``inheritance_mode`` overrides ``options.inheritance_mode`` so the
    orchestrator can render the same slide twice (once layered, once flat)
    when the user asked for ``"both"``. Pass ``"flat"`` or ``"layered"``;
    ``None`` falls back to ``options.inheritance_mode`` (used by direct
    callers that want a single mode).
    """
    mode = inheritance_mode or options.inheritance_mode
    if mode == "both":
        mode = "layered"  # primary view in both-mode
    show_inherited_shapes = part_show_master_sp(slide.part)
    svg, media = assemble_slide(
        pkg, slide, palette,
        theme_fonts=theme_fonts,
        media_subdir=options.media_subdir,
        embed_images=options.embed_images,
        keep_hidden=options.keep_hidden,
        inheritance_mode=mode,
        asset_name_map=options.asset_name_map,
        strict=options.strict,
        diagnostics=diagnostics,
    )
    return SlideArtifact(
        index=slide.index,
        svg=svg,
        media_files=media,
        layout_part_path=slide.layout.path if slide.layout else None,
        master_part_path=slide.master.path if slide.master else None,
        show_inherited_shapes=show_inherited_shapes,
    )


def _convert_inheritance_parts(
    pkg: OoxmlPackage,
    default_theme: PartRef | None,
    options: ConvertOptions,
    result: ConvertResult,
) -> None:
    """Render every master and layout in the deck to its own SVG (layered mode).

    We deliberately render *all* masters / layouts, not only the ones a slide
    references. Multi-style template packages routinely ship more design
    surfaces than the embedded sample slides exercise, and dropping unused
    ones discards the bulk of the template's design intent.
    """
    # Collect unique parts in document order so output filenames are
    # deterministic for a given .pptx.
    seen_masters: dict[str, PartRef] = {}
    for master in pkg.iter_all_masters():
        if master.path not in seen_masters:
            seen_masters[master.path] = master

    layouts_with_parent: list[tuple[PartRef, PartRef]] = []
    seen_layout_paths: set[str] = set()
    for layout, parent_master in pkg.iter_all_layouts_with_parent():
        if layout.path in seen_layout_paths:
            continue
        seen_layout_paths.add(layout.path)
        layouts_with_parent.append((layout, parent_master))

    for seq, part in enumerate(seen_masters.values(), start=1):
        theme = pkg.resolve_theme(part) or default_theme
        palette = _make_palette(
            part,
            theme,
            options,
            result,
            part_path=part.path,
        )
        _colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts)
        result.masters.append(_render_part(
            pkg, part, palette, options, result.diagnostics, fonts,
            role="master", seq=seq, theme_part=theme,
        ))
    for seq, (layout, parent_master) in enumerate(layouts_with_parent, start=1):
        theme = pkg.resolve_theme(parent_master) or default_theme
        palette = _make_palette(
            parent_master,
            theme,
            options,
            result,
            part_path=layout.path,
        )
        _colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts)
        result.layouts.append(_render_part(
            pkg, layout, palette, options, result.diagnostics, fonts,
            role="layout", seq=seq, parent_master=parent_master,
            theme_part=theme,
        ))


def _render_part(
    pkg: OoxmlPackage,
    part: PartRef,
    palette: ColorPalette,
    options: ConvertOptions,
    diagnostics: list[ImportDiagnostic],
    theme_fonts: dict[str, str],
    *,
    role: str,
    seq: int,
    parent_master: PartRef | None = None,
    theme_part: PartRef | None = None,
) -> PartArtifact:
    """Render a master/layout part, returning a PartArtifact with output filename."""
    svg, media = assemble_part_solo(
        pkg, part, palette,
        role=role,
        parent_master=parent_master,
        theme_fonts=theme_fonts,
        media_subdir=options.media_subdir,
        embed_images=options.embed_images,
        keep_hidden=options.keep_hidden,
        asset_name_map=options.asset_name_map,
        strict=options.strict,
        diagnostics=diagnostics,
    )
    stem = PurePosixPath(part.path).stem  # e.g. "slideLayout3"
    safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or role
    filename = f"{role}_{seq:02d}_{safe_stem}.svg"
    return PartArtifact(
        role=role,
        part_path=part.path,
        filename=filename,
        svg=svg,
        media_files=media,
        parent_master_part_path=parent_master.path if parent_master is not None else None,
        theme_part_path=theme_part.path if theme_part is not None else None,
        show_master_shapes=(
            part_show_master_sp(part) if role == "layout" else True
        ),
    )


def _path_lexists(path: Path) -> bool:
    """Return whether a path or symlink exists without following the symlink."""
    return path.exists() or path.is_symlink()


def _managed_svg_paths(output_dir: Path) -> list[Path]:
    """Return converter-owned SVG files without traversing user directories."""
    managed: list[Path] = []
    for dirname, filename_re in (
        ("svg", _MANAGED_PRIMARY_SVG_RE),
        ("svg-flat", _MANAGED_FLAT_SVG_RE),
    ):
        svg_dir = output_dir / dirname
        if svg_dir.is_symlink():
            managed.append(svg_dir)
            continue
        if not svg_dir.is_dir():
            continue
        managed.extend(
            path
            for path in svg_dir.iterdir()
            if filename_re.fullmatch(path.name)
            and (path.is_file() or path.is_symlink())
        )
        inheritance = svg_dir / "inheritance.json"
        if dirname == "svg" and _path_lexists(inheritance):
            managed.append(inheritance)
    return managed


def _referenced_local_paths(
    output_dir: Path,
    svg_paths: list[Path],
) -> set[Path]:
    """Resolve local media referenced by converter-owned SVGs."""
    referenced: set[Path] = set()
    output_abs = output_dir.absolute()
    for svg_path in svg_paths:
        if svg_path.is_symlink() or not svg_path.is_file():
            continue
        try:
            svg_text = svg_path.read_text(encoding="utf-8")
        except (OSError, UnicodeError):
            continue
        for raw_href in _SVG_HREF_RE.findall(svg_text):
            href = unescape(raw_href)
            parsed = urlsplit(href)
            if parsed.scheme or parsed.netloc or not parsed.path:
                continue
            href_path = unquote(parsed.path)
            if Path(href_path).is_absolute():
                continue
            target = Path(os.path.normpath(str(svg_path.parent / href_path)))
            try:
                relative = target.absolute().relative_to(output_abs)
            except ValueError:
                continue
            if relative.parts:
                referenced.add(relative)
    return referenced


def _validated_relative_paths(paths: set[str | Path]) -> set[Path]:
    """Normalize caller-supplied managed paths and reject output escapes."""
    normalized: set[Path] = set()
    for value in paths:
        path = Path(value)
        if (
            path.drive
            or path.anchor
            or path.is_absolute()
            or not path.parts
            or ".." in path.parts
        ):
            raise ValueError(f"Managed artifact path must stay relative: {value}")
        normalized.add(path)
    return normalized


def _reject_symlink_ancestors(
    root: Path,
    relative_paths: set[Path],
) -> None:
    """Reject managed paths that would traverse a preserved user symlink."""
    for relative in relative_paths:
        current = root
        for component in relative.parts[:-1]:
            current /= component
            if current.is_symlink():
                raise RuntimeError(
                    "Managed artifact path crosses an unmanaged symlink: "
                    f"{relative}"
                )


def _remove_managed_paths(candidate_dir: Path, relative_paths: set[Path]) -> None:
    """Remove only the previous converter roster from a candidate workspace."""
    _reject_symlink_ancestors(candidate_dir, relative_paths)
    parents: set[Path] = set()
    for relative in sorted(
        relative_paths,
        key=lambda item: len(item.parts),
        reverse=True,
    ):
        target = candidate_dir / relative
        if target.is_symlink() or target.is_file():
            target.unlink()
        elif target.is_dir():
            raise RuntimeError(
                "Managed artifact path collides with a preserved directory: "
                f"{relative}"
            )
        parent = target.parent
        while parent != candidate_dir:
            parents.add(parent)
            parent = parent.parent

    for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True):
        if parent.is_symlink() or not parent.is_dir():
            continue
        try:
            parent.rmdir()
        except OSError:
            pass


def _overlay_staged_tree(staged_dir: Path, candidate_dir: Path) -> None:
    """Overlay generated artifacts without overwriting unmanaged user files."""
    for source in sorted(staged_dir.rglob("*")):
        relative = source.relative_to(staged_dir)
        target = candidate_dir / relative
        _reject_symlink_ancestors(candidate_dir, {relative})
        if source.is_symlink():
            raise RuntimeError(
                f"Generated artifact must not be a symlink: {relative}"
            )
        if source.is_dir():
            if (
                target.is_symlink()
                or (_path_lexists(target) and not target.is_dir())
            ):
                raise RuntimeError(
                    f"Generated artifact collides with unmanaged path: {relative}"
                )
            target.mkdir(parents=True, exist_ok=True)
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        if _path_lexists(target):
            if target.is_dir() or target.is_symlink():
                raise RuntimeError(
                    f"Generated artifact collides with unmanaged path: {relative}"
                )
            if target.read_bytes() != source.read_bytes():
                raise RuntimeError(
                    f"Generated artifact collides with unmanaged file: {relative}"
                )
        shutil.copy2(source, target)


def publish_staged_workspace(
    output_dir: Path,
    staged_dir: Path,
    *,
    managed_root_files: set[str | Path] | None = None,
    managed_relative_paths: set[str | Path] | None = None,
) -> None:
    """Atomically publish generated artifacts while preserving user files.

    Converter-owned SVGs, their local media references, and the named managed
    artifacts are replaced as one roster. Everything else already present in
    the output directory is copied into the candidate unchanged.
    """
    output_dir = output_dir.absolute()
    staged_dir = staged_dir.absolute()
    if (
        output_dir == staged_dir
        or output_dir in staged_dir.parents
        or staged_dir in output_dir.parents
    ):
        raise ValueError(
            "Staged and output workspaces must not contain one another"
        )
    output_resolved = output_dir.resolve(strict=False)
    try:
        Path.cwd().resolve().relative_to(output_resolved)
    except ValueError:
        pass
    else:
        raise RuntimeError(
            "Output workspace must not contain the current working directory"
        )
    if not staged_dir.is_dir():
        raise ValueError(f"Staged workspace does not exist: {staged_dir}")
    if (
        output_dir.is_symlink()
        or (_path_lexists(output_dir) and not output_dir.is_dir())
    ):
        raise RuntimeError(f"Output path must be a real directory: {output_dir}")

    output_dir.parent.mkdir(parents=True, exist_ok=True)
    transaction_dir = Path(tempfile.mkdtemp(
        prefix=f".{output_dir.name}.publish-",
        dir=output_dir.parent,
    ))
    candidate_dir = transaction_dir / "candidate"
    backup_dir = transaction_dir / "previous"
    preserve_backup = False

    try:
        if output_dir.is_dir():
            shutil.copytree(output_dir, candidate_dir, symlinks=True)
        else:
            candidate_dir.mkdir()

        managed_svg = _managed_svg_paths(output_dir)
        relative_paths = {
            path.relative_to(output_dir)
            for path in managed_svg
        }
        relative_paths.update(_referenced_local_paths(output_dir, managed_svg))
        relative_paths.add(Path("conversion-report.json"))
        relative_paths.update(_validated_relative_paths(managed_root_files or set()))
        relative_paths.update(_validated_relative_paths(managed_relative_paths or set()))
        _remove_managed_paths(candidate_dir, relative_paths)
        _overlay_staged_tree(staged_dir, candidate_dir)

        if output_dir.is_dir():
            try:
                os.replace(output_dir, backup_dir)
                os.replace(candidate_dir, output_dir)
            except BaseException as publish_error:
                try:
                    if _path_lexists(backup_dir):
                        if _path_lexists(output_dir):
                            failed_output = transaction_dir / "failed-publish"
                            os.replace(output_dir, failed_output)
                        os.replace(backup_dir, output_dir)
                except BaseException as restore_error:
                    if (
                        not _path_lexists(backup_dir)
                        and _path_lexists(output_dir)
                    ):
                        raise publish_error
                    preserve_backup = _path_lexists(backup_dir)
                    raise RuntimeError(
                        "Failed to publish the new workspace and restore the "
                        "previous workspace; recovery directory: "
                        f"{transaction_dir}"
                    ) from restore_error
                raise
        else:
            os.replace(candidate_dir, output_dir)
    finally:
        if not preserve_backup:
            shutil.rmtree(transaction_dir, ignore_errors=True)


def _write_artifact_tree(
    output_dir: Path,
    result: ConvertResult,
    options: ConvertOptions,
) -> None:
    """Write a complete converter roster into an empty staging directory.

    Layout:
      - ``svg/``        primary view (layered when emitted, otherwise flat)
      - ``svg-flat/``   self-contained per-slide renders (only in "both" mode)
      - ``<media_subdir>/`` shared image assets, referenced by both views
    """
    output_dir.mkdir(parents=True, exist_ok=True)
    svg_dir = output_dir / "svg"
    svg_dir.mkdir(exist_ok=True)
    media_dir = output_dir / options.media_subdir
    media_written: dict[str, bytes] = {}

    def _collect_media(media: dict[str, bytes]) -> None:
        for filename, blob in media.items():
            _validate_media_filename(filename)
            if filename in media_written:
                if media_written[filename] != blob:
                    raise RuntimeError(
                        f"Asset filename collision with different bytes: {filename}"
                    )
                continue
            media_written[filename] = blob

    # Layered mode: write masters and layouts first so they sort ahead of slides.
    for art in result.masters:
        (svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
        _collect_media(art.media_files)
    for art in result.layouts:
        (svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
        _collect_media(art.media_files)

    # Slides (primary view).
    for art in result.slides:
        target = svg_dir / f"slide_{art.index:02d}.svg"
        target.write_text(art.svg, encoding="utf-8")
        _collect_media(art.media_files)

    # Inheritance graph alongside the layered SVGs (only meaningful when we
    # actually emitted a layered view).
    if options.inheritance_mode in {"layered", "both"}:
        _write_inheritance_json(svg_dir, result)

    # Flat companion view (only when result.flat_slides is populated).
    if result.flat_slides:
        flat_dir = output_dir / "svg-flat"
        flat_dir.mkdir(exist_ok=True)
        for art in result.flat_slides:
            target = flat_dir / f"slide_{art.index:02d}.svg"
            target.write_text(art.svg, encoding="utf-8")
            _collect_media(art.media_files)

    _write_conversion_report(output_dir, result)
    if media_written:
        media_dir.mkdir(parents=True, exist_ok=True)
    for filename, blob in media_written.items():
        target = media_dir / filename
        if _path_lexists(target):
            if (
                target.is_symlink()
                or not target.is_file()
                or target.read_bytes() != blob
            ):
                raise RuntimeError(
                    f"Asset filename collision with different bytes: {filename}"
                )
            continue
        target.write_bytes(blob)


def _write_artifacts(
    output_dir: Path,
    result: ConvertResult,
    options: ConvertOptions,
) -> None:
    """Stage a complete conversion, then atomically publish its exact roster."""
    output_dir = output_dir.absolute()
    output_dir.parent.mkdir(parents=True, exist_ok=True)
    staging_root = Path(tempfile.mkdtemp(
        prefix=f".{output_dir.name}.convert-",
        dir=output_dir.parent,
    ))
    staged_dir = staging_root / "generated"
    try:
        _write_artifact_tree(staged_dir, result, options)
        publish_staged_workspace(output_dir, staged_dir)
    finally:
        shutil.rmtree(staging_root, ignore_errors=True)


def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None:
    """Write the user-visible tolerant-import report."""
    report = {
        "schemaVersion": 1,
        "source": result.source_file,
        "mode": "strict" if result.strict else "tolerant",
        "summary": {
            "slides": len(result.slides),
            "warnings": len(result.diagnostics),
        },
        "diagnostics": [item.to_dict() for item in result.diagnostics],
    }
    (output_dir / "conversion-report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def _write_inheritance_json(svg_dir: Path, result: ConvertResult) -> None:
    """Record layered parentage plus source-owned shape-visibility booleans."""
    layout_by_path = {art.part_path: art.filename for art in result.layouts}
    master_by_path = {art.part_path: art.filename for art in result.masters}

    inheritance = {
        "masters": [
            {
                "file": art.filename,
                "partPath": art.part_path,
                "themePath": art.theme_part_path,
            }
            for art in result.masters
        ],
        "layouts": [
            {
                "file": art.filename,
                "partPath": art.part_path,
                "master": master_by_path.get(art.parent_master_part_path or ""),
                "parentPartPath": art.parent_master_part_path,
                "themePath": art.theme_part_path,
                "showMasterShapes": art.show_master_shapes,
            }
            for art in result.layouts
        ],
        "slides": [
            {
                "file": f"slide_{slide.index:02d}.svg",
                "index": slide.index,
                "layout": layout_by_path.get(slide.layout_part_path or ""),
                "master": master_by_path.get(slide.master_part_path or ""),
                "showInheritedShapes": slide.show_inherited_shapes,
            }
            for slide in result.slides
        ],
    }
    (svg_dir / "inheritance.json").write_text(
        json.dumps(inheritance, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
