# SPDX-License-Identifier: MIT OR Apache-2.0
# SPDX-FileCopyrightText: The Ferrocene Developers

# This module is responsible for generating the ID of the whole document. This
# ID is supposed to uniquely identify the revision of the document, and it must
# not change if the underlying content doesn't change.
#
# We can't use the git commit for the ID, as a commit could change something
# unrelated to the content. Instead, we hash the parsed doctrees after all the
# transforms executed, to capture the manually written content and the content
# generated by other extensions, and use that as the ID.

from docutils import nodes
from sphinx.transforms import SphinxTransform
import hashlib
import os
import sphinx
import struct


class Hasher:
    def __init__(self):
        self.state = hashlib.sha1()

    def string(self, string):
        encoded = string.encode("utf-8")

        self.state.update(struct.pack("<Q", len(encoded)))
        self.state.update(encoded)

    def node(self, node):
        self.string(node.tagname)

        if isinstance(node, nodes.Text):
            self.state.update(b"\xFF")  # Text node
            self.string(str(node))
        else:
            self.state.update(struct.pack("<Q", len(node.non_default_attributes())))
            for name, value in node.attlist():
                # The <document source=".."> attribute contains absolute paths
                # in it, breaking reproducibility.
                if isinstance(node, nodes.document) and name == "source":
                    continue

                self.string(name)
                self.string(repr(value))

            for child in node.children:
                self.state.update(b"\xFE")  # Enter child node
                self.node(child)
                self.state.update(b"\xFD")  # Exit child node

    def finalize(self):
        return self.state.hexdigest()


class HashDocument(SphinxTransform):
    default_priority = 999

    def apply(self):
        hasher = Hasher()
        hasher.node(self.document)

        self.env.ferrocene_document_ids[self.env.docname] = hasher.finalize()


def env_purge_doc(app, env, docname):
    if not hasattr(env, "ferrocene_document_ids"):
        env.ferrocene_document_ids = {}
    if docname in env.ferrocene_document_ids:
        del env.ferrocene_document_ids[docname]


def env_merge_info(app, env, docnames, other):
    for doc in docnames:
        env.ferrocene_document_ids[doc] = other.ferrocene_document_ids[doc]


def env_updated(app, env):
    hasher = Hasher()
    for document, hash in sorted(app.env.ferrocene_document_ids.items()):
        hasher.string(document)
        hasher.string(hash)

    old_id = getattr(env, "ferrocene_document_id", None)
    new_id = f"{app.config.ferrocene_id}-{hasher.finalize()}"

    app.env.ferrocene_document_id = new_id

    # Mark all documents as updated if the ID changed since the last
    # invocation, to force all templates to use the new ID.
    if old_id != new_id:
        return env.all_docs.keys()


def html_page_context(app, pagename, templatename, context, doctree):
    context["document_id"] = app.env.ferrocene_document_id


def write_document_id(app):
    with sphinx.util.display.progress_message("writing document id"):
        with open(os.path.join(app.outdir, "document-id.txt"), "w") as f:
            f.write(f"{app.env.ferrocene_document_id}\n")


def build_finished(app, exception):
    if exception is not None:
        return
    if app.builder.name == "html":
        write_document_id(app)


def setup(app):
    # We're not using an EnvCollector since it doesn't allow to set the
    # priority for the transform. Instead, our custom transform can set its
    # priority to 999. The downside is we have to connect events manually.
    app.add_transform(HashDocument)
    app.connect("env-purge-doc", env_purge_doc)
    app.connect("env-merge-info", env_merge_info)

    app.connect("env-updated", env_updated)
    app.connect("html-page-context", html_page_context)
    app.connect("build-finished", build_finished)
