97-documentation/ns_report_publisher/publisher.py

from __future__ import annotations

import datetime as dt
import html
import json
import os
import re
import shutil
import tempfile
from pathlib import Path
from typing import Any

from .catalog import load_external_artifact_targets, load_external_code_views, load_external_context_edges
from .context_graph import build_context_graph, index_payload, relationship_ledger_summary, render_building_block_view, render_context_graph, render_deployment_view, render_gap_list, render_realization_view, render_relationship_ledger_findings
from .discovery import discover_documents, is_derived_path
from .model import ProcessedDocument, PublicationContext
from .knowledge import knowledge_presentation
from .navigation import build_navigation_model, ordered_navigation_documents, render_navigation
from .processing import process_documents
from .resolver import build_artifact_targets, select_artifact_targets
from .renderer import collect_local_images, markdown_to_html
from .relationship_ledger import LEDGER_PATH, load as load_relationship_ledger
from .theme import load_css, load_page_template

TOOL_VERSION = "1.4.0-processing-foundation"
CODE_SUFFIXES = {".py", ".sh", ".json", ".tsv", ".go", ".js", ".ts"}
PRESENTATION_DIMENSIONS = {
    "meaning": ("Meaning", "Bedeutung, Ziel, Regel oder Begründung"),
    "artifact": ("Artifact", "Konkretes, referenzierbares oder materialisierbares Element"),
    "relationship": ("Beziehung", "Verbindung, Zuordnung oder Abhängigkeit zwischen Elementen"),
    "context-time": ("Kontext & Zeit", "Ausführung, Evidence, Feedback oder zeitlicher Kontext"),
    "structure": ("Struktur", "Gruppierung, Orientierung oder Navigation"),
    "control": ("Steuerung", "Verbindliche Anleitung, Governance oder Konfiguration"),
    "report": ("Report", "Konkreter Bericht, Summary oder Validierungsnachweis"),
}


def environment_marker(target_runtime: str) -> str:
    """Return an explicit marker only for non-public report publications."""
    labels = {"test": "Testumgebung", "production": "Lokale Produktion"}
    label = labels.get(target_runtime)
    return (f'<p class="environment-marker" role="status">{html.escape(label)}</p>'
            if label else "")


def report_slug_for_source(source_dir: Path) -> str:
    name = source_dir.resolve().name
    normalized = re.sub(r"[^A-Za-z0-9_.-]+", "-", name).strip("-._").lower()
    if not normalized:
        return "documentation"
    if normalized == "enterprisearchitecture":
        return "enterprise-architecture"
    if normalized == "solutionarchitecture":
        return "solution-architecture"
    return normalized


def architecture_level_for_source(source_dir: Path) -> str:
    """Presentation layer, deliberately separate from presentation dimension."""
    return {
        "enterprise-architecture": "Enterprise",
        "solution-architecture": "Solution",
        "engineering-tools": "Engineering",
        "runtime": "Runtime",
        "engineering-platform": "Engineering Platform",
    }.get(report_slug_for_source(source_dir), "Unbestimmt")


def _copy_local_images(context: PublicationContext) -> None:
    for document in context.documents:
        for reference in collect_local_images(document):
            path_part = reference.split("#", 1)[0].split("?", 1)[0]
            if not path_part:
                continue
            source = (document.path.parent / path_part).resolve()
            try:
                relative = source.relative_to(context.source_root)
            except ValueError:
                context.unresolved_references.append(
                    f"{document.relative_path.as_posix()} -> image outside source root: {reference}"
                )
                continue
            if not source.is_file():
                context.unresolved_references.append(
                    f"{document.relative_path.as_posix()} -> missing image: {reference}"
                )
                continue
            destination = context.output_root / relative
            destination.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, destination)
            context.copied_assets.add(relative.as_posix())

def _publish_code_views(source_root: Path, output_root: Path) -> dict[str, Path]:
    """Publish bounded, selectable source views for approved code files."""
    result: dict[str, Path] = {}
    basename_targets: dict[str, list[Path]] = {}
    for source in sorted(source_root.rglob("*")):
        if not source.is_file():
            continue
        relative_path = source.relative_to(source_root)
        if is_derived_path(relative_path):
            continue
        if source.suffix not in CODE_SUFFIXES or source.stat().st_size > 100 * 1024:
            continue
        relative = relative_path.as_posix()
        if any(x in relative.lower() for x in (".env", "secret", "credential", "password")):
            continue
        target = output_root / "code" / (relative.replace("/", "-").replace(".", "-") + ".html")
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text("<!doctype html><meta charset=\"utf-8\"><main><h1>" + html.escape(relative) + "</h1><pre><code>" + html.escape(source.read_text(encoding="utf-8", errors="replace")) + "</code></pre></main>", encoding="utf-8")
        result[relative] = target
        basename_targets.setdefault(source.name, []).append(target)
    for basename, targets in basename_targets.items():
        if len(targets) == 1:
            result[basename] = targets[0]
    return result


def _write_code_index(code_targets: dict[str, Path], output_root: Path) -> None:
    """Publish only relative, approved source-view locations for other reports."""
    files = {
        relative: target.relative_to(output_root).as_posix()
        for relative, target in code_targets.items()
        # Basename aliases are only added when unique; a root-level file is
        # therefore still a valid repository-relative qualified reference.
    }
    (output_root / "code-index.json").write_text(
        json.dumps({"schemaVersion": "1.0", "files": files}, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )



def _indent_fragment(fragment: str, spaces: int) -> str:
    """Indent generated HTML without changing significant code-block content."""
    if not fragment:
        return ""
    prefix = " " * spaces
    return "\n".join(prefix + line if line else line for line in fragment.splitlines())


def _place_context_board_after_document_title(markdown_html: str, board: str) -> str:
    """Place the derived visual context directly after the document title.

    A document without a rendered top-level heading keeps the board at the
    beginning, which preserves the visual context instead of silently hiding
    it.  The board is generated markup and therefore never changes Markdown
    source content.
    """
    if not board:
        return markdown_html
    heading = re.search(r"</h1\s*>", markdown_html, flags=re.IGNORECASE)
    if heading is None:
        return board + "\n" + markdown_html
    return markdown_html[:heading.end()] + "\n" + board + markdown_html[heading.end():]

def _render_page(
    template: str,
    *,
    title: str,
    css_href: str,
    navigation: str,
    artifact_id: str,
    presentation_dimension: str,
    presentation_help_href: str,
    body: str,
    document_pager: str,
    target_runtime: str,
) -> str:
    artifact_html = (
        f'<p class="artifact-id">{html.escape(artifact_id, quote=False)}</p>'
        if artifact_id else ""
    )
    presentation = PRESENTATION_DIMENSIONS.get(presentation_dimension)
    presentation_html = ""
    if presentation:
        label, tooltip = presentation
        badge_content = (
            f'<span class="presentation-badge-icon" aria-hidden="true">◆</span>'
            f'<span class="presentation-badge-label">{html.escape(label, quote=False)}</span>'
            f'<span class="presentation-badge-tooltip" role="tooltip">{html.escape(tooltip, quote=False)}</span>'
        )
        if presentation_help_href:
            badge_content = (
                f'<a href="{html.escape(presentation_help_href, quote=True)}" '
                f'aria-label="Präsentationsdimension: {html.escape(label, quote=True)}. Darstellungsnotation öffnen.">'
                f'{badge_content}</a>'
            )
        presentation_html = (
            f'<aside class="presentation-badge presentation-dimension-{html.escape(presentation_dimension, quote=True)}" '
            f'aria-label="Präsentationsdimension: {html.escape(label, quote=True)}">{badge_content}</aside>'
        )
    replacements = {
        "{{TITLE}}": html.escape(title, quote=False),
        "{{CSS_HREF}}": html.escape(css_href, quote=True),
        "{{NAVIGATION}}": _indent_fragment(navigation, 16),
        "{{ENVIRONMENT_MARKER}}": _indent_fragment(environment_marker(target_runtime), 8),
        "{{ARTIFACT_ID}}": _indent_fragment(artifact_html, 16),
        "{{PRESENTATION_DIMENSION}}": _indent_fragment(presentation_html, 16),
        "{{BODY}}": _indent_fragment(body, 16),
        "{{DOCUMENT_PAGER}}": _indent_fragment(document_pager, 16),
    }
    page = template
    for marker, value in replacements.items():
        page = page.replace(marker, value)
    return page



def _render_document_pager(
    ordered_documents: list,
    current_index: int,
    current_output_path: Path,
) -> str:
    def link(document, css_class: str, label: str) -> str:
        href = Path(os.path.relpath(document.output_path, current_output_path.parent)).as_posix()
        return (
            f'<a class="{css_class}" href="{html.escape(href, quote=True)}">'
            f'<span class="document-pager-direction">{html.escape(label, quote=False)}</span>'
            f'<span class="document-pager-title">{html.escape(document.title, quote=False)}</span>'
            '</a>'
        )

    previous = ordered_documents[current_index - 1] if current_index > 0 else None
    following = ordered_documents[current_index + 1] if current_index + 1 < len(ordered_documents) else None
    previous_html = link(previous, "document-pager-previous", "← Voriges Dokument") if previous else '<span></span>'
    next_html = link(following, "document-pager-next", "Nächstes Dokument →") if following else '<span></span>'
    return "\n".join([
        '<nav class="document-pager" aria-label="Dokumentreihenfolge">',
        f'    {previous_html}',
        f'    {next_html}',
        '</nav>',
    ])

def _presentation_notation_html() -> str:
    return """
<section id="darstellungsnotation" class="presentation-notation">
<h2>Darstellungsnotation</h2>
<p>Die Symbole ordnen ein Wissenselement ein. Die englischen Schlüssel bleiben für Verträge und Übersetzungen stabil; die deutsche Bezeichnung ist die primäre Lesesprache.</p>
<dl><dt><span aria-hidden="true">☁</span> Bedeutung <code>meaning</code></dt><dd>Ziel, Regel, Begriff oder Begründung.</dd>
<dt><span aria-hidden="true">▣</span> Artefakt <code>artifact</code></dt><dd>Konkretes, referenzierbares oder materialisierbares Element.</dd>
<dt><span aria-hidden="true">⟷</span> Beziehung <code>relationship</code></dt><dd>Verbindung, Zuordnung oder Abhängigkeit zwischen Elementen.</dd>
<dt><span aria-hidden="true">◷</span> Kontext und zeitliche Einordnung <code>context-time</code></dt><dd>Ausführung, Evidence, Feedback oder zeitlicher Kontext.</dd></dl>
</section>""".strip()


def _render_knowledge_directory(processed_documents: list[ProcessedDocument], output_root: Path) -> str:
    entries = []
    for processed in processed_documents:
        presentation = knowledge_presentation(processed.source)
        if not presentation:
            continue
        entries.append({
            "identifier": processed.metadata.get("id", "").strip(),
            "title": processed.title,
            "kind": presentation.kind,
            "kind_label": "Glossar" if presentation.kind == "glossary" else "Learning Nuggets",
            "short": presentation.short_text,
            "topic": presentation.topic or "Ohne Thema",
            "term_type": presentation.term_type,
            "href": Path(os.path.relpath(processed.output_path, output_root)).as_posix(),
        })
    if not entries:
        return "<section class=\"knowledge-directory\"><h1>Wissensübersicht</h1>" + _presentation_notation_html() + "<p>Keine Wissensartefakte vorhanden.</p></section>"
    entries.sort(key=lambda item: (item["kind_label"], item["topic"].casefold(), item["title"].casefold()))
    groups: dict[tuple[str, str], list[dict[str, str]]] = {}
    for entry in entries:
        groups.setdefault((entry["kind_label"], entry["topic"]), []).append(entry)
    out = [
        '<section class="knowledge-directory">',
        '<h1>Wissensübersicht</h1>',
        '<p>Glossarbegriffe erklären Begriffe. Learning Nuggets vermitteln Zusammenhänge. Die Übersicht wird vollständig aus den vorhandenen Wissensartefakten generiert.</p>',
        _presentation_notation_html(),
        '<div class="knowledge-directory-controls">',
        '<label for="knowledge-search">Wissen durchsuchen</label>',
        '<input id="knowledge-search" class="knowledge-search" type="search" placeholder="Begriff, Thema oder Kurzfassung" autocomplete="off">',
        '<div id="knowledge-search-status" role="status" aria-live="polite"></div>',
        '</div>',
    ]
    for (kind_label, topic), items in groups.items():
        out.append(f'<section class="knowledge-group" data-knowledge-group><h2>{html.escape(kind_label)} · {html.escape(topic)}</h2><div class="knowledge-card-grid">')
        for item in items:
            search = " ".join((item["title"], item["short"], item["topic"], item["term_type"], item["identifier"])).casefold()
            meta = " · ".join(x for x in (item["topic"], item["term_type"], item["identifier"]) if x)
            out.append(
                f'<article class="knowledge-card {item["kind"]}" data-knowledge-card data-search="{html.escape(search, quote=True)}">'
                f'<h3><a href="{html.escape(item["href"], quote=True)}">{html.escape(item["title"])}</a></h3>'
                f'<p>{html.escape(item["short"])}</p><div class="knowledge-card-meta">{html.escape(meta)}</div></article>'
            )
        out.append('</div></section>')
    out.extend([
        '</section>',
        """<script>
(() => {
 const input=document.getElementById('knowledge-search');
 const status=document.getElementById('knowledge-search-status');
 const cards=[...document.querySelectorAll('[data-knowledge-card]')];
 const groups=[...document.querySelectorAll('[data-knowledge-group]')];
 const filter=()=>{ const q=input.value.trim().toLocaleLowerCase(); let visible=0; cards.forEach(card=>{ const show=!q||card.dataset.search.includes(q); card.hidden=!show; if(show) visible++; }); groups.forEach(group=>{ group.hidden=![...group.querySelectorAll('[data-knowledge-card]')].some(card=>!card.hidden); }); status.textContent=`${visible} Wissenselemente angezeigt`; };
 input.addEventListener('input',filter); filter();
})();
</script>""",
    ])
    return "\n".join(out)

def write_html_report(source_dir: Path, runtime_root: Path, target_runtime: str = "test", output_name: str | None = None) -> dict[str, Any]:
    source_root = source_dir.resolve()
    if not source_root.exists() or not source_root.is_dir():
        raise ValueError(f"Source directory fehlt oder ist kein Verzeichnis: {source_dir}")

    runtime_root = runtime_root.resolve()
    reports_root = runtime_root / "web" / "reports"
    slug = output_name or report_slug_for_source(source_root)
    final_output_root = reports_root / slug
    final_output_root.parent.mkdir(parents=True, exist_ok=True)
    reports_root.mkdir(parents=True, exist_ok=True)

    temp_prefix = "." + re.sub(r"[^A-Za-z0-9_.-]+", "-", slug) + "-"
    temporary_root = Path(tempfile.mkdtemp(prefix=temp_prefix, dir=final_output_root.parent))
    try:
        documents = discover_documents(source_root, temporary_root)
        relationship_ledger, relationship_ledger_findings = load_relationship_ledger(source_root / LEDGER_PATH)
        local_architecture_level = architecture_level_for_source(source_root)
        for document in documents:
            document.metadata.setdefault("repository_id", slug)
            document.metadata.setdefault("architecture_level", local_architecture_level)
        navigation = build_navigation_model(documents)
        external_artifact_targets = load_external_artifact_targets(
            reports_root,
            excluded_report_root=slug,
        )
        processed_documents, artifact_targets = process_documents(
            documents,
            external_artifact_targets=external_artifact_targets,
        )
        external_documents = {
            target.document.output_path: target.document
            for candidates in external_artifact_targets.values()
            for target in select_artifact_targets(candidates)
        }
        external_context_edges = load_external_context_edges(
            reports_root,
            excluded_report_root=slug,
        )
        context_graph = build_context_graph(
            documents,
            list(external_documents.values()),
            external_context_edges,
        )
        ledger_summary = relationship_ledger_summary(
            context_graph, relationship_ledger, relationship_ledger_findings
        )
        context = PublicationContext(
            source_root=source_root,
            output_root=temporary_root,
            documents=documents,
            navigation=navigation,
            artifact_targets=artifact_targets,
            processed_documents=processed_documents,
        )

        assets = temporary_root / "assets"
        assets.mkdir(parents=True, exist_ok=True)
        (assets / "report.css").write_text(load_css(), encoding="utf-8")
        ledger_source = source_root / LEDGER_PATH
        if ledger_source.is_file():
            shutil.copy2(ledger_source, temporary_root / "relationship-ledger.json")
        template = load_page_template()
        code_targets = _publish_code_views(source_root, temporary_root)
        _write_code_index(code_targets, temporary_root)
        external_code_views = load_external_code_views(
            reports_root,
            excluded_report_root=slug,
        )

        pages: list[str] = []
        gap_output = temporary_root / "context-relationship-gaps.html"
        ledger_findings_output = temporary_root / "relationship-ledger-findings.html"
        ordered_documents = ordered_navigation_documents(navigation)
        document_positions = {document.path: index for index, document in enumerate(ordered_documents)}
        for processed_document in processed_documents:
            document = processed_document.source
            document.output_path.parent.mkdir(parents=True, exist_ok=True)
            css_href = Path(os.path.relpath(assets / "report.css", document.output_path.parent)).as_posix()
            code_views = {
                key: Path(os.path.relpath(value, document.output_path.parent)).as_posix()
                for key, value in code_targets.items()
            }
            code_views.update({
                key: Path(os.path.relpath(value, document.output_path.parent)).as_posix()
                for key, value in external_code_views.items()
            })
            gap_href = Path(os.path.relpath(gap_output, document.output_path.parent)).as_posix()
            # Ein gemeinsames Zeichenbrett stellt sämtliche direkten
            # Beziehungen in ihren Architektur-Swimlanes dar. Es steht direkt
            # nach der Dokumentüberschrift, damit der Lesekontext vor dem
            # Fließtext sichtbar ist.
            board = render_building_block_view(document, context_graph) or render_deployment_view(document, context_graph) or render_realization_view(document, context_graph)
            if board:
                graph_markup = board
            else:
                graph_markup = render_context_graph(
                    document,
                    context_graph,
                    gap_href,
                    architecture_level=local_architecture_level,
                )
            body = _place_context_board_after_document_title(
                markdown_to_html(processed_document, code_views), graph_markup
            )
            final_document_path = final_output_root / document.output_path.relative_to(temporary_root)
            home_href = Path(os.path.relpath(reports_root / "index.html", final_document_path.parent)).as_posix()
            navigation_html = render_navigation(navigation, document, home_href)
            knowledge_href = Path(os.path.relpath(temporary_root / "knowledge" / "index.html", document.output_path.parent)).as_posix()
            navigation_html += f'\n<div class="knowledge-navigation-link"><a href="{html.escape(knowledge_href, quote=True)}">Wissensübersicht</a></div>'
            ledger_href = Path(os.path.relpath(ledger_findings_output, document.output_path.parent)).as_posix()
            navigation_html += f'\n<div class="knowledge-navigation-link"><a href="{html.escape(ledger_href, quote=True)}">Beziehungs- und Qualitätsbefunde</a></div>'
            document_pager = _render_document_pager(
                ordered_documents,
                document_positions[document.path],
                document.output_path,
            )
            page = _render_page(
                template,
                title=document.title,
                css_href=css_href,
                navigation=navigation_html,
                artifact_id=document.metadata.get("id", "").strip(),
                presentation_dimension=document.metadata.get("presentation_dimension", "").strip(),
                presentation_help_href=knowledge_href + "#darstellungsnotation",
                body=body,
                document_pager=document_pager,
                target_runtime=target_runtime,
            )
            document.output_path.write_text(page, encoding="utf-8")
            pages.append(document.output_path.relative_to(temporary_root).as_posix())

        gap_page = _render_page(
            template,
            title="Fehlende Beziehungen",
            css_href="assets/report.css",
            navigation='<div class="navigation-home"><a href="index.html">← Dokumentation</a></div>',
            artifact_id="",
            presentation_dimension="report",
            presentation_help_href="knowledge/index.html#darstellungsnotation",
            body=render_gap_list(context_graph, gap_output),
            document_pager="",
            target_runtime=target_runtime,
        )
        gap_output.write_text(gap_page, encoding="utf-8")
        pages.append(gap_output.relative_to(temporary_root).as_posix())
        ledger_page = _render_page(
            template,
            title="Beziehungsledger-Befunde",
            css_href="assets/report.css",
            navigation='<div class="navigation-home"><a href="index.html">← Dokumentation</a></div>',
            artifact_id="",
            presentation_dimension="report",
            presentation_help_href="knowledge/index.html#darstellungsnotation",
            body=render_relationship_ledger_findings(
                context_graph, ledger_findings_output, relationship_ledger, relationship_ledger_findings
            ),
            document_pager="",
            target_runtime=target_runtime,
        )
        ledger_findings_output.write_text(ledger_page, encoding="utf-8")
        pages.append(ledger_findings_output.relative_to(temporary_root).as_posix())
        context_index_output = temporary_root / "context-relationship-index.json"
        context_index_output.write_text(
            json.dumps(index_payload(context_graph, relationship_ledger), ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
        context_catalog_output = temporary_root / "context-node-catalog.json"
        context_catalog_output.write_text(
            json.dumps(
                {
                    "schemaVersion": "1.0",
                    "materialization": "regenerated during report publication",
                    "nodes": [
                        {
                            "identifier": item.metadata.get("id", ""),
                            "title": item.title,
                            "repository": item.metadata.get("repository_id", slug),
                            "architectureLevel": item.metadata.get("architecture_level", "Unbestimmt"),
                            "presentationDimension": item.metadata.get("presentation_dimension", ""),
                            "summary": item.metadata.get("summary", ""),
                            "path": item.relative_path.as_posix(),
                        }
                        for item in sorted(context_graph.documents_by_id.values(), key=lambda node: node.metadata.get("id", ""))
                    ],
                },
                ensure_ascii=False,
                indent=2,
            ) + "\n",
            encoding="utf-8",
        )

        knowledge_output = temporary_root / "knowledge" / "index.html"
        knowledge_output.parent.mkdir(parents=True, exist_ok=True)
        knowledge_page = _render_page(
            template,
            title="Wissensübersicht",
            css_href="../assets/report.css",
            navigation='<div class="navigation-home"><a href="../index.html">← Dokumentation</a></div>',
            artifact_id="",
            presentation_dimension="",
            presentation_help_href="",
            body=_render_knowledge_directory(processed_documents, knowledge_output.parent),
            document_pager="",
            target_runtime=target_runtime,
        )
        knowledge_output.write_text(knowledge_page, encoding="utf-8")
        _copy_local_images(context)
        for processed_document in processed_documents:
            for finding in processed_document.validation_findings:
                entry = (
                    f"{processed_document.relative_path.as_posix()} -> "
                    f"[[{finding.reference}]]"
                )
                if finding.code == "artifact-reference-ambiguous":
                    context.ambiguous_references.append(entry)
                else:
                    context.unresolved_references.append(entry)

        generated_at = dt.datetime.now().isoformat(timespec="seconds")
        index: dict[str, Any] = {
            "tool": "ns",
            "toolVersion": TOOL_VERSION,
            "source": str(source_root),
            "targetRuntime": target_runtime,
            "outputRoot": str(final_output_root),
            "generatedAt": generated_at,
            "pageCount": len(pages),
            "pages": pages,
            "knowledgePage": "knowledge/index.html",
            "artifactTargetCount": sum(len(items) for items in artifact_targets.values()),
            "processedDocumentCount": len(processed_documents),
            "artifactReferenceCount": sum(len(items) for items in artifact_targets.values()),
            "processedArtifactReferenceCount": sum(
                len(document.artifact_references) for document in processed_documents
            ),
            "contextGraph": {
                "edgeCount": len(context_graph.edges),
                "eligibleDocumentCount": sum(context_graph.eligible(item) for item in documents),
                "isolatedDocumentIds": sorted(
                    item.metadata["id"].strip() for item in context_graph.documents_by_id.values()
                    if context_graph.eligible(item)
                    and not context_graph.incoming(item.metadata["id"].strip())
                    and not context_graph.outgoing(item.metadata["id"].strip())
                ),
                "missingRelationshipPage": "context-relationship-gaps.html",
                "index": "context-relationship-index.json",
                "nodeCatalog": "context-node-catalog.json",
            },
            "relationshipLedger": {
                "openFindingCount": ledger_summary["openCount"],
                "duplicateMarkdownKeys": [" → ".join(key) for key, _ in ledger_summary["duplicates"]],
                "orphanLedgerKeys": sorted(ledger_summary["orphanLedger"]),
                "duplicateLedgerKeys": sorted(key for key, _ in ledger_summary["duplicateLedger"]),
                "parseFindings": ledger_summary["parseFindings"],
            },
            "artifactTargets": sorted(
                (
                    {
                        "identifier": identifier,
                        "canonicalId": target.document.metadata.get("id", "").strip() or identifier,
                        "page": target.document.output_path.relative_to(temporary_root).as_posix(),
                        "title": target.document.title,
                        "priority": target.priority,
                        "sourceKind": target.source_kind,
                        "sourcePath": target.document.relative_path.as_posix(),
                        "artifactType": target.document.metadata.get("artifact_type", ""),
                        "presentationDimension": target.document.metadata.get("presentation_dimension", ""),
                        "summary": target.document.metadata.get("summary", ""),
                        "primaryAlias": target.document.metadata.get("primary_alias", ""),
                        "synonyms": target.document.metadata.get("synonyms", []),
                        "knowledgeShortText": (knowledge_presentation(target.document).short_text if knowledge_presentation(target.document) else ""),
                        "knowledgeDetailText": (knowledge_presentation(target.document).detail_text if knowledge_presentation(target.document) else ""),
                        "knowledgeTopic": (knowledge_presentation(target.document).topic if knowledge_presentation(target.document) else ""),
                        "knowledgeTermType": (knowledge_presentation(target.document).term_type if knowledge_presentation(target.document) else ""),
                    }
                    for identifier, targets in build_artifact_targets(documents).items()
                    for target in targets
                ),
                key=lambda item: (item["identifier"], item["page"]),
            ),
            "unresolvedArtifactReferences": sorted(set(context.unresolved_references)),
            "ambiguousArtifactReferences": sorted(set(context.ambiguous_references)),
            "copiedAssets": sorted(context.copied_assets),
        }
        (temporary_root / "report-index.json").write_text(
            json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
        )

        if final_output_root.exists():
            shutil.rmtree(final_output_root)
        temporary_root.replace(final_output_root)
        return index
    except Exception:
        shutil.rmtree(temporary_root, ignore_errors=True)
        raise