# Copyright (c) 2026 Sensmetry, UAB. All Rights Reserved.

import argparse
import re
from pathlib import Path
from datetime import datetime
import json
import hashlib
import subprocess
import tempfile
from typing import Any, cast

import os

import frontmatter
import mistune
from jinja2 import Environment
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration

import graphviz
import pypandoc

import syside
import html as html_lib

## ============================================================================
## CONSTANTS
## ============================================================================

TOC_LEVELS = 4

FONT_REGULAR = "Georgia"
FONT_LIGHT = "Georgia"

SCRIPTS_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPTS_DIR.parent
ASSETS_DIR = PROJECT_DIR / "assets"
MODELS_DIR = PROJECT_DIR / "models"
METADATA_DIR = PROJECT_DIR / "metadata"

PATH_LOGO = ASSETS_DIR / "logo_placeholder.png"
PATH_CSS_STYLES = ASSETS_DIR / "styles.css"
PATH_WORD_TEMPLATE = ASSETS_DIR / "template.docx"
PATH_SRS_TEMPLATE = MODELS_DIR / "syside_demo_requirements.md"
PATH_VERSIONS_JSON = METADATA_DIR / "versions.json"


## ============================================================================
## RUNTIME CACHE
## ============================================================================


class RuntimeCache:
    modelRef: syside.Model = None  # type: ignore
    modelRefTagged: syside.Model | None = None
    modelGenericNodeDict: dict[str, list[syside.Element]] = {}
    modelConnectionDict: dict[
        tuple[str, str], dict[syside.Element, set[syside.Element]]
    ] = {}
    modelVerifyReqDict: dict[
        str, list[syside.RequirementVerificationMembership]
    ] = {}

    @staticmethod
    def updateModelRef(model: syside.Model) -> None:
        RuntimeCache.modelRef = model

    @staticmethod
    def updateModelRefTagged(model: syside.Model | None) -> None:
        RuntimeCache.modelRefTagged = model

    @staticmethod
    def get_filtered_nodes(metatype: str) -> list[syside.Element]:
        if metatype in RuntimeCache.modelGenericNodeDict:
            return RuntimeCache.modelGenericNodeDict["metatype"]
        else:
            RuntimeCache.modelGenericNodeDict["metatype"] = [
                x
                for x in RuntimeCache.modelRef.nodes(
                    getattr(syside, metatype), include_subtypes=True
                )
            ]
            return RuntimeCache.modelGenericNodeDict["metatype"]

    @staticmethod
    def get_requirement_verification_memberships(
        verify_name: str,
    ) -> list[syside.RequirementVerificationMembership]:
        if verify_name not in RuntimeCache.modelVerifyReqDict:
            named_req_verifications: list[
                syside.RequirementVerificationMembership
            ] = []
            for item in [
                item
                for item in RuntimeCache.modelRef.nodes(
                    getattr(syside, "RequirementVerificationMembership"),
                    include_subtypes=True,
                )
            ]:
                if item.scoped_owner and item.scoped_owner.name == verify_name:
                    named_req_verifications.append(item)
            RuntimeCache.modelVerifyReqDict[verify_name] = (
                named_req_verifications
            )
        return RuntimeCache.modelVerifyReqDict[verify_name]

    @staticmethod
    def get_named_connections(
        source_keyword: str, end_keyword: str
    ) -> dict[syside.Element, set[syside.Element]]:
        key = (source_keyword, end_keyword)

        if key not in RuntimeCache.modelConnectionDict:
            derived_connections: dict[syside.Element, set[syside.Element]] = {}

            all_connections: list[syside.ConnectionUsage] = (
                RuntimeCache.get_filtered_nodes("ConnectionUsage")  # type: ignore
            )

            for connection in all_connections:
                parent: list[syside.Element] = []
                children: set[syside.Element] = set()
                for end_feature in connection.end_features.collect():
                    # actual metadata name lives here:
                    # end_feature.prefixes[0][1].heritage[0][0].type_target.element.short_name
                    if name := end_feature.declared_name:
                        if name.startswith("original"):
                            parent.append(end_feature.heritage.elements[0])
                        elif name.startswith("derive"):
                            children.add(end_feature.heritage.elements[0])

                assert len(parent) <= 1  # should not have multiple parents
                if len(parent) > 0:
                    derived_connections[parent[0]] = children

            RuntimeCache.modelConnectionDict[key] = derived_connections

        return RuntimeCache.modelConnectionDict[key]


## ============================================================================
## SYSML MODEL LOADING AND PARSING
## ============================================================================


class ModelParsing:
    @staticmethod
    def load_model_from_project(path: str) -> syside.Model | None:
        """Load the SysML model from a project using syside"""
        try:
            project_root = Path(path).resolve()
            print("Loading SysML v2 models:")

            sysml_files = list(project_root.rglob("*.sysml"))
            for f in sysml_files:
                print(f"  └ {str(f).split('/')[-1]}")
            if not sysml_files:
                print("No SysML files found in project directory")
                return None

            model, diags = syside.try_load_model([str(f) for f in sysml_files])
            if diags.contains_errors(True):
                print(f"\n**Syside diagnostics:**\n{diags}")

            if not model:
                print("Failed to load model - model is None")
                return None

        except Exception as e:
            print(f"Error loading model: {e}")
            return None

        return model

    @staticmethod
    def get_document_containing(
        model: syside.Model, element_name: str
    ) -> syside.SharedMutex[syside.Document]:
        for document in model.all_docs:
            with document.lock() as locked:
                try:
                    # duplicates are checked by Syside internally
                    _ = locked.root_node[element_name]
                    return document
                except KeyError as _:
                    pass
        raise ValueError(
            f"Element not found in root namespace: {element_name}."
        )

    @staticmethod
    def get_root(
        model: syside.Model, root_package_name: str
    ) -> syside.Package | None:
        try:
            doc = ModelParsing.get_document_containing(model, root_package_name)
            with doc.lock() as locked:
                root_package = locked.root_node[root_package_name].try_cast(
                    syside.Package
                )
                assert root_package, (
                    f"{locked.url} has no {root_package} Package"
                )

            return root_package
        except Exception as e:
            print(f"Error getting root: {e}")
            return None

    @staticmethod
    def get_requirement_verifications(
        element: syside.Element, verify_name: str
    ) -> list[syside.Element]:
        verifications: list[syside.Element] = []
        for (
            req_membership
        ) in RuntimeCache.get_requirement_verification_memberships(verify_name):
            try:
                target = req_membership.owned_member_element.heritage.elements[  # type: ignore
                    0
                ]
                if target == element:
                    verifications.append(
                        req_membership.scoped_owner.scoped_owner  # type: ignore
                    )
            except Exception as e:
                print(f"Failed to extract verify link: {e}")
                pass
        return verifications

    @staticmethod
    def get_allocations(
        element: syside.Element,
        data_or_none: list[syside.AllocationUsage] | None = None,
    ) -> list[syside.Element | syside.Type]:
        data: list[syside.AllocationUsage] = (
            data_or_none
            if data_or_none is not None
            else RuntimeCache.get_filtered_nodes("AllocationUsage")  # type: ignore
        )

        allocations: list[syside.Element | syside.Type] = []
        for allocation in data:
            end_features = allocation.end_features.collect()
            source: syside.Element = [
                item.heritage.elements[0]
                for item in end_features
                if item.name == "source"
            ][0]
            target: syside.Element = [
                item.heritage.elements[0]
                for item in end_features
                if item.name == "target"
            ][0]

            final_source = (
                allocation.scoped_owner if source.name == "self" else source
            )
            final_target = (
                allocation.scoped_owner if target.name == "self" else target
            )
            if final_source == element and final_target is not None:
                allocations.append(final_target)
        return allocations

    @staticmethod
    def get_derived(
        element: syside.Element,
        source_keyword: str,
        end_keyword: str,
    ) -> set[syside.Element] | None:
        derived_connections = RuntimeCache.get_named_connections(
            source_keyword, end_keyword
        )
        if element in derived_connections.keys():
            return derived_connections[element]
        return None

    @staticmethod
    def get_derived_parents(
        element: syside.Element,
        source_keyword: str,
        end_keyword: str,
    ) -> list[syside.Element]:
        derived_connections = RuntimeCache.get_named_connections(
            source_keyword, end_keyword
        )
        parent_elements = [
            parent
            for parent, children in derived_connections.items()
            if element in children
        ]
        return parent_elements

    @staticmethod
    def get_owned_of_type_recursive(
        element: syside.Element, match_type: type
    ) -> list[syside.Feature]:
        allocations: list[syside.Feature] = []
        if isinstance(element, syside.Type):
            for feature in element.features.collect():
                if type(feature) is match_type:
                    allocations.append(feature)

        for child in element.owned_elements.collect():
            allocations.extend(
                ModelParsing.get_owned_of_type_recursive(child, match_type)
            )

        return allocations

    @staticmethod
    def extract_elements(
        model: syside.Model,
        parent_element_name: str,
        metatype: str,
        ignore_abstract_metatypes: bool,
    ) -> list[syside.Element]:
        """
        Generic function to extract elements from SysML model.
        """

        qualified_parts = parent_element_name.replace("'", "").split("::")
        assert len(qualified_parts) > 0, "Incorrect target_package"

        root_node: syside.Namespace = ModelParsing.get_root(
            model, qualified_parts[0]
        )  # type: ignore

        parent_namespace = root_node
        for part in qualified_parts[1:]:
            parent_namespace = parent_namespace[part].cast(syside.Namespace)

        target_type = getattr(syside, metatype)

        findings = ModelParsing.traverse_children(
            parent_namespace, target_type, ignore_abstract_metatypes
        )

        return findings

    @staticmethod
    def traverse_children(
        element: syside.Namespace,
        target_type: type,
        ignore_abstract_metatypes: bool,
    ) -> list[syside.Element]:
        findings: list[syside.Element] = []
        for child in element.owned_members.collect():
            if isinstance(child, syside.Namespace):
                findings.extend(
                    ModelParsing.traverse_children(
                        child, target_type, ignore_abstract_metatypes
                    )
                )
            if type(child) is target_type and (
                not ignore_abstract_metatypes
                or (
                    isinstance(child, syside.Type)
                    and child.is_abstract is False
                )
            ):
                findings.append(child)
        return findings

    @staticmethod
    def extract_attributes(
        model: syside.Model,
        elements: list[syside.Element],
        attributes: dict[str, str],
    ) -> dict[syside.Element, list[str]]:
        result: dict[syside.Element, list[str]] = {}
        compiler = syside.Compiler()

        for element in elements:
            values: list[str] = []
            for name, attr_type in attributes.items():
                match attr_type:
                    case "OwningNamespace":
                        if element.owning_namespace is not None:
                            values.append(
                                str(element.owning_namespace.qualified_name)
                            )
                        else:
                            values.append("")
                    case "Req_Implemented":
                        allocations = ModelParsing.get_allocations(element)
                        sorted_named = sorted(
                            [" - " + str(item.name) for item in allocations]
                        )
                        values.append("<br>".join(sorted_named))
                    case "Req_Verified":
                        verifications = (
                            ModelParsing.get_requirement_verifications(
                                element, "verifyRequirement"
                            )
                        )
                        sorted_named = sorted(
                            [" - " + str(item.name) for item in verifications]
                        )
                        values.append("<br>".join(sorted_named))
                    case "Req_Derivations":
                        derivations = ModelParsing.get_derived(
                            element, "original", "derive"
                        )
                        if derivations:
                            sorted_named = sorted(
                                [
                                    f'<a href="#{Formatting.get_anchor_id(item.name if item.name is not None else "")}">{item.name if item.name is not None else ""}</a>'
                                    for item in derivations
                                ]
                            )
                            values.append("<br>".join(sorted_named))
                        else:
                            values.append("")
                    case "Req_Parents":
                        parents = ModelParsing.get_derived_parents(
                            element, "original", "derive"
                        )
                        if parents:
                            sorted_named = sorted(
                                [
                                    f'<a href="#{Formatting.get_anchor_id(item.name if item.name is not None else "")}">{item.name if item.name is not None else ""}</a>'
                                    for item in parents
                                ]
                            )
                            values.append("<br>".join(sorted_named))
                        else:
                            values.append("")
                    case "Req_DependencyGraph":
                        graph = Exposed.create_connection_dep_graph_svg(
                            element, "original", "derive"
                        )
                        values.append(graph)
                    case "ElementName":
                        values.append(
                            element.name if element.name is not None else ""
                        )
                    case "AttributeUsage":
                        element = cast(syside.AttributeUsage, element)
                        try:
                            object = element.get_member(name)
                            if object is None:
                                matching_attrs = [
                                    x
                                    for x in element.members.collect()
                                    if type(x) is syside.AttributeUsage
                                    and x.name == name
                                ]
                                if len(matching_attrs) > 0:
                                    object = matching_attrs[0]

                            if object is None:
                                values.append("")
                            else:
                                value, _ = compiler.evaluate_feature(
                                    feature=object,  # type: ignore
                                    scope=element,
                                )
                                values.append(value)  # type: ignore
                        except Exception as e:
                            print(f"Error extracting attribute: {e}")
                            values.append("")
                    case "Documentation":
                        doc_string = ""
                        for doc in element.documentation.collect():
                            if (len(name) == 0 and doc.name is None) or (
                                doc.name == name
                            ):
                                doc_string += doc.body
                        values.append(doc_string)
                    case _:
                        values.append("")

            result[element] = values

        return result


## ============================================================================
## MARKDOWN --> HTML --> PDF FUNCTIONS
## ============================================================================


class Converters:
    @staticmethod
    def markdown_to_html(
        md_path: Path, model: syside.Model, update_changelog: bool
    ) -> tuple[str, dict[str, object]]:
        post = frontmatter.load(md_path)
        md_content = post.content
        metadata: dict[str, object] = post.metadata

        # Set up Jinja2 environment
        env = Environment()

        # Register template functions
        env.globals["title"] = lambda: Exposed.title(metadata)
        env.globals["toc"] = Exposed.toc
        env.globals["page_break"] = Exposed.page_break
        env.globals["revision_history"] = Exposed.revision_history
        env.globals["changelog"] = lambda **kwargs: Exposed.changelog(
            update_changelog, **kwargs
        )
        env.globals["text"] = Exposed.list_to_text
        env.globals["get_docs"] = lambda **kwargs: Exposed.get_docs(
            model, **kwargs
        )
        env.globals["list_items"] = Exposed.list_items
        env.globals["generic_table"] = Exposed.generic_table
        env.globals["headless_table"] = Exposed.headless_table
        env.globals["repeat_for_each_item"] = Exposed.repeat_for_each_item
        env.globals["create_connection_dep_graph_svg"] = (
            Exposed.create_connection_dep_graph_svg
        )
        env.globals["traceability_matrix"] = (
            lambda **kwargs: Exposed.build_traceability_matrix(model, **kwargs)
        )
        env.globals["get_children_with_attributes"] = (
            lambda **kwargs: Exposed.get_children_with_attributes(
                model, **kwargs
            )
        )
        env.globals["unique_headers_from_qualified_name"] = (
            Exposed.unique_headers_from_qualified_name
        )

        # Render Jinja2 template
        template = env.from_string(md_content)
        rendered_md = template.render()

        # Convert markdown to HTML
        body_html: str = mistune.html(rendered_md)  # type: ignore

        # Add Table of Contents
        body_html, toc_html = Converters.generate_toc_from_html(body_html)
        body_html = body_html.replace(
            '<div id="toc-placeholder"></div>', toc_html
        )

        html = f"""<!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <title>{metadata.get("document_title", "")}</title>
        </head>
        <body>
            {body_html}
        </body>
        </html>
        """

        return html, metadata

    @staticmethod
    def html_to_file(
        html: str, output_file: Path, css: str | None = None
    ) -> None:
        if css:
            html = html.replace("</head>", f"<style>{css}</style></head>")
        output_file.write_text(html, encoding="utf-8")
        print(f"  └ HTML: {output_file}")

    @staticmethod
    def html_to_pdf(
        html_string: str, output_path: Path, css: str | None = None
    ) -> None:
        font_config = FontConfiguration()

        base_url = str(output_path.parent)

        if css:
            stylesheets = CSS(string=css, font_config=font_config, base_url=base_url)
        else:
            stylesheets = CSS(font_config=font_config)

        HTML(string=html_string, base_url=base_url).write_pdf(
            target=output_path,
            stylesheets=[stylesheets],
            font_config=font_config,
        )

        print(f"  └ PDF:  {output_path}")

    @staticmethod
    def html_to_docx(
        html: str, output_file: Path, reference_doc: Path | None = None
    ) -> None:
        # Optional: use reference doc for styling
        pypandoc.convert_text(
            html,
            "docx",
            format="html",
            outputfile=output_file,
            extra_args=[f"--reference-doc={reference_doc}"]
            if reference_doc
            else [],
        )

        print(f"  └ DOCX: {output_file}")

    @staticmethod
    def generate_toc_from_html(html: str) -> tuple[str, str]:
        """Extract headers and build TOC with links"""
        header_pattern = r"<h([1-6])>(.*?)</h\1>"
        headers = re.findall(header_pattern, html)

        # First pass: add IDs to headers in the HTML
        modified_html = html
        for level, text in headers:
            if int(level) <= TOC_LEVELS:
                anchor_id = re.sub(r"[^\w\s-]", "", text.lower()).replace(
                    " ", "-"
                )
                old_header = f"<h{level}>{text}</h{level}>"
                new_header = f'<h{level} id="{anchor_id}">{text}</h{level}>'
                modified_html = modified_html.replace(old_header, new_header, 1)

        # Second pass: build TOC
        toc_items: list[str] = []
        for level, text in headers:
            if int(level) <= TOC_LEVELS:
                anchor_id = re.sub(r"[^\w\s-]", "", text.lower()).replace(
                    " ", "-"
                )
                toc_items.append(
                    f'<a href="#{anchor_id}" data-level="{level}">{text}</a>'
                )

        toc_html = '<div class="toc">\n' + "\n".join(toc_items) + "\n</div>"
        return modified_html, toc_html


## ============================================================================
## Formatters and css-relared
## ============================================================================


class Formatting:
    @staticmethod
    def combine_css_styles(metadata: dict[str, object], output_dir: Path) -> str:
        base_css = PATH_CSS_STYLES.read_text(encoding="utf-8")
        logo_rel_path = Path(os.path.relpath(PATH_LOGO, output_dir)).as_posix()
        header_footer_css = Formatting.build_header_footer_css(
            metadata, logo_rel_path
        )
        combined_css = base_css + "\n\n" + header_footer_css
        return combined_css

    @staticmethod
    def margin_box(position: str, content: str, **extra_styles: str) -> str:
        """Generate a margin box with common styles"""
        base_styles = """
            font-family: var(--doc-font-family);
            font-weight: var(--medium-font-weight);
            font-size: var(--header-footer-size);
            color: var(--color-header-footer);
        """

        extra = "\n".join([f"{k}: {v};" for k, v in extra_styles.items()])

        return f"""
            @{position} {{
                content: {content};
                {base_styles}
                {extra}
            }}
        """

    @staticmethod
    def build_header_footer_css(
        metadata: dict[str, object], logo_url: str | None = None
    ) -> str:
        """Return a CSS string that defines header/footer layout based on metadata."""
        project_title = metadata.get("project_title", "")
        project_reference = metadata.get("project_reference", "")
        document_version = metadata.get("document_version", "1.0")
        company_website = metadata.get("company_website", "")
        company_email = metadata.get("company_email", "")
        company_phone = metadata.get("company_phone", "")

        logo_styles = {
            "background": f"url('{logo_url}')" if logo_url else "none",
            "background-position": "right center",
            "background-repeat": "no-repeat",
            "background-size": "147.1px auto",
            "width": "147.1px",
            "height": "auto",
        }

        header_footer_css = f"""
            @page {{
                {Formatting.margin_box("top-left", f'"{project_reference}"')}
                {Formatting.margin_box("top-center", f'"{project_title}"')}
                {Formatting.margin_box("top-right", f'"Revision {document_version}"')}
                {Formatting.margin_box("bottom-left", rf'"{company_website}\A{company_email}\A{company_phone}"', **{"white-space": "pre"})}
                {Formatting.margin_box("bottom-center", r'"Page " counter(page) " of " counter(pages)')}
                {Formatting.margin_box("bottom-right", '""', **logo_styles)}
            }}
        """

        return header_footer_css

    @staticmethod
    def get_anchor_id(text: str) -> str:
        """Generate anchor ID from text, matching generate_toc_from_html logic"""
        return re.sub(r"[^\w\s-]", "", text.lower()).replace(" ", "-")


## ============================================================================
## CHANGELOG AND HISTORY MANAGEMENT
## ============================================================================


class VersionManager:
    @staticmethod
    def get_latest_git_tag() -> str | None:
        """Get the latest git tag, or None if no tags exist"""
        try:
            result = subprocess.run(
                ["git", "describe", "--tags", "--abbrev=0"],
                capture_output=True,
                text=True,
                check=False,
                cwd=PROJECT_DIR,
            )
            if result.returncode == 0:
                return result.stdout.strip()
            return None
        except Exception as e:
            print(f"Warning: Could not get git tag: {e}")
            return None

    @staticmethod
    def get_all_git_tags() -> set[str]:
        """Get all git tags as a set"""
        try:
            result = subprocess.run(
                ["git", "tag"],
                capture_output=True,
                text=True,
                check=False,
                cwd=PROJECT_DIR,
            )
            if result.returncode == 0:
                return (
                    set(result.stdout.strip().replace("v", "").split("\n"))
                    if result.stdout.strip()
                    else set()
                )
            return set()
        except Exception as e:
            print(f"Warning: Could not get git tags: {e}")
            return set()

    @staticmethod
    def get_document_version(template_path: Path) -> str | None:
        """Extract document_version from markdown frontmatter"""
        try:
            post = frontmatter.load(template_path)
            return post.metadata.get("document_version")  # type: ignore
        except Exception as e:
            print(f"Warning: Could not read document version: {e}")
            return None

    @staticmethod
    def get_version_description(template_path: Path) -> str | None:
        """Extract document_version_description from markdown frontmatter"""
        try:
            post = frontmatter.load(template_path)
            return post.metadata.get("document_version_description", "")  # type: ignore
        except Exception as e:
            print(f"Warning: Could not read version description: {e}")
            return ""

    @staticmethod
    def load_versions() -> dict[str, list[dict[str, str]]]:
        if not PATH_VERSIONS_JSON.exists():
            return {"revisions": []}

        try:
            with open(PATH_VERSIONS_JSON, "r", encoding="utf-8") as f:
                revisions: dict[str, list[dict[str, str]]] = json.load(f)
                return revisions
        except Exception as e:
            print(f"Warning: Could not load versions.json: {e}")
            return {"revisions": []}

    @staticmethod
    def save_versions(versions_data: dict[str, list[dict[str, str]]]) -> None:
        try:
            # Create metadata directory if it doesn't exist
            METADATA_DIR.mkdir(parents=True, exist_ok=True)

            with open(PATH_VERSIONS_JSON, "w", encoding="utf-8") as f:
                json.dump(versions_data, f, indent=2, ensure_ascii=False)
        except Exception as e:
            print(f"Error: Could not save versions.json: {e}")

    @staticmethod
    def extract_requirements_dict(
        model: syside.Model,
        root_package: str = "Algorithmic Trading Requirements",
    ) -> dict[str, dict[str, Any]]:
        """
        Extract requirements from model as a dict: {req_id: {name, description, hash}}
        """
        try:
            elements = ModelParsing.extract_elements(
                model,
                parent_element_name=root_package,
                metatype="RequirementUsage",
                ignore_abstract_metatypes=True,
            )

            attributes = {
                "Name": "ElementName",
                "Description": "Documentation",
                "SuccessCriteria": "Documentation",
                "Justification": "Documentation",
            }

            req_atrributes = ModelParsing.extract_attributes(
                model, elements, attributes
            )

            req_hashes: dict[str, dict[str, Any]] = {}
            for req, req_attr in req_atrributes.items():
                req_id = str(req.name)

                # Compute hash for change detection
                hash_content = json.dumps(
                    {"name": req_id, "attributes": req_attr}, sort_keys=True
                )
                req_hash = hashlib.sha256(hash_content.encode()).hexdigest()

                req_hashes[req_id] = {
                    "name": req.name,
                    "attributes": dict(zip(attributes.keys(), req_attr)),
                    "hash": req_hash,
                }

            return req_hashes
        except Exception as e:
            print(f"Warning: Could not extract requirements: {e}")
            return {}

    @staticmethod
    def compute_delta(
        old_reqs: dict[str, dict[str, Any]], new_reqs: dict[str, dict[str, Any]]
    ) -> str:
        """Compute delta between two requirement sets."""
        if not old_reqs:
            return f"Initial version ({len(new_reqs)} requirements)"

        added = set(new_reqs.keys()) - set(old_reqs.keys())
        deleted = set(old_reqs.keys()) - set(new_reqs.keys())
        common = set(old_reqs.keys()) & set(new_reqs.keys())

        modified = sum(
            1
            for req_id in common
            if old_reqs[req_id]["hash"] != new_reqs[req_id]["hash"]
        )

        parts: list[str] = []
        if added:
            parts.append(f"{len(added)} added")
        if modified:
            parts.append(f"{modified} modified")
        if deleted:
            parts.append(f"{len(deleted)} deleted")

        return ", ".join(parts) if parts else "No changes"

    @staticmethod
    def load_model_from_tag(tag: str) -> syside.Model | None:
        """
        Load SysML model from a specific git tag by extracting models folder to temp directory.
        Returns None if tag doesn't exist or model can't be loaded.
        """
        if RuntimeCache.modelRefTagged is None:
            try:
                # Calculate relative path from repo root to models dir
                models_rel_path = MODELS_DIR.relative_to(PROJECT_DIR)

                # Create temp directory and extract models folder from tag
                with tempfile.TemporaryDirectory() as temp_dir:
                    temp_path = Path(temp_dir)

                    # Use git archive to extract the models directory
                    result = subprocess.run(
                        ["git", "archive", tag, str(models_rel_path)],
                        capture_output=True,
                        check=True,
                        cwd=PROJECT_DIR,
                    )

                    # Extract tar to temp directory
                    subprocess.run(
                        ["tar", "-xf", "-", "-C", str(temp_path)],
                        input=result.stdout,
                        check=True,
                    )

                    # Load model from extracted directory
                    extracted_models_path = temp_path / models_rel_path
                    print(
                        f"\n[{tag}] ==========================================="
                    )
                    model = ModelParsing.load_model_from_project(
                        str(extracted_models_path)
                    )

                    RuntimeCache.updateModelRefTagged(model)

            except subprocess.CalledProcessError as e:
                print(f"Error: Could not extract models from tag {tag}: {e}")
                return None
            except Exception as e:
                print(f"Error: Could not load model from tag {tag}: {e}")
                return None

        return RuntimeCache.modelRefTagged

    @staticmethod
    def update_version_history(
        current_model: syside.Model,
        template_path: Path,
        root_package: str = "Algorithmic Trading Requirements",
    ) -> None:
        """
        Update versions.json:
        1. Clean history - keep only tagged versions
        2. Add current version if it's not tagged
        """
        doc_version = VersionManager.get_document_version(template_path)
        if not doc_version:
            print(
                "Warning: No document_version in frontmatter, skipping version history update"
            )
            return

        all_tags = VersionManager.get_all_git_tags()

        versions_data = VersionManager.load_versions()
        cleaned_revisions = [
            rev
            for rev in versions_data["revisions"]
            if rev["version"] in all_tags
        ]

        if doc_version in all_tags:
            existing_versions = [rev["version"] for rev in cleaned_revisions]
            if doc_version not in existing_versions:
                print(f"\nAdding tagged version: {doc_version}")
                versions_data["revisions"] = cleaned_revisions
                VersionManager.save_versions(versions_data)
            else:
                print(f"Version {doc_version} is tagged and already in history")
            return

        current_reqs = VersionManager.extract_requirements_dict(
            current_model, root_package
        )
        print(f"Extracting requirements... {len(current_reqs)} found")
        print("==================================================")
        latest_tag = VersionManager.get_latest_git_tag()

        print(f"\nComparing with git tag: {latest_tag}")

        old_reqs: dict[str, dict[str, Any]] = {}
        if latest_tag:
            tagged_model = VersionManager.load_model_from_tag(latest_tag)
            if tagged_model:
                old_reqs = VersionManager.extract_requirements_dict(
                    tagged_model, root_package
                )
                print(f"Extracting requirements... {len(old_reqs)} found")
                print("==================================================")
            else:
                print(
                    f"\nWarning: Could not load model from tag {latest_tag}, treating as initial version"
                )

        changes = VersionManager.compute_delta(old_reqs, current_reqs)

        description = VersionManager.get_version_description(template_path)
        if not description:
            description = "Version " + doc_version

        new_revision = {
            "version": doc_version,
            "date": datetime.now().strftime("%Y-%m-%d"),
            "description": description,
            "changes": changes,
        }

        versions_data["revisions"] = cleaned_revisions + [new_revision]
        VersionManager.save_versions(versions_data)

        print(f"\nDetecting changes ({latest_tag} → current)... {changes}")


## ============================================================================
## JINJA CALLABLE FUNCTIONS
## ============================================================================


class Exposed:
    _unique_headers: set[str] = set()

    @staticmethod
    def title(metadata: dict[str, object]) -> str:
        """Generate title page HTML from metadata"""

        html = f"""<div class="title-page">
        <h1 class="document-title">{metadata.get("document_title", "")}</h1>
        <p class="project-context">for the</p>
        <h2 class="project-title">{metadata.get("project_title", "")}</h2>
        <p class="project-reference">{metadata.get("project_reference", "")}</p>
        <p class="project-context"></p>
        <p class="doc-version">Revision: v{metadata.get("document_version", "")}</p>
        <p class="date-generated">Date: {datetime.now().strftime("%Y-%m-%d")}</p>
        <p class="author">Author: {metadata.get("author", "")}</p>
    </div>"""

        return html

    @staticmethod
    def toc() -> str:
        return '<div id="toc-placeholder"></div>'

    @staticmethod
    def page_break() -> str:
        return '<div style="page-break-before: always;"></div>'

    @staticmethod
    def create_connection_dep_graph_svg(
        element: syside.Element,
        source_keyword: str,
        end_keyword: str,
    ) -> str:
        parents = ModelParsing.get_derived_parents(
            element, source_keyword, end_keyword
        )
        derivations = ModelParsing.get_derived(
            element, source_keyword, end_keyword
        )

        dot = graphviz.Digraph(comment=f"Dependency Graph for {element.name}")

        dot.attr(rankdir="LR")
        dot.attr(
            "node", shape="box", style="rounded, filled", fillcolor="white"
        )
        dot.attr(
            "node",
            fontname=FONT_REGULAR,
            fontsize="10",
            height="0.4",
            width="1.2",
        )
        dot.attr("edge", fontname=FONT_LIGHT, fontsize="8")

        element_label = element.name if element.name is not None else ""
        element_id = element.name if element.name is not None else "element"

        # Main element node with custom color
        dot.node(element_id, element_label, fillcolor="orange")

        if parents:
            for i, parent in enumerate(parents):
                parent_label = parent.name if parent.name is not None else ""
                parent_id = (
                    parent.name if parent.name is not None else f"parent_{i}"
                )
                dot.node(parent_id, parent_label)
                dot.edge(parent_id, element_id, label="derives to")

        if derivations:
            for i, derived in enumerate(derivations):
                derived_label = derived.name if derived.name is not None else ""
                derived_id = (
                    derived.name if derived.name is not None else f"derived_{i}"
                )
                dot.node(derived_id, derived_label)
                dot.edge(element_id, derived_id, label="derives to")

        svg_string = dot.pipe(format="svg").decode("utf-8")

        return f'<div class="dependency-graph">{svg_string}</div>'

    @staticmethod
    def get_docs(
        model: syside.Model, root_package: str, doc_name: str | None = None
    ) -> list[list[str | None]]:
        all_docs: list[syside.Documentation] = ModelParsing.extract_elements(
            model,
            parent_element_name=root_package,
            metatype="Documentation",
            ignore_abstract_metatypes=False,
        )  # type: ignore

        doc_list: list[list[str | None]] = []
        for doc in all_docs:
            if (
                (doc_name is None and doc.name is None)
                or (doc.name == doc_name)
                or (doc_name == "*")
            ):
                doc_list.append([doc_name, doc.body])

        return doc_list

    @staticmethod
    def get_children_with_attributes(
        model: syside.Model,
        root_package: str,
        metatype: str,
        ignore_abstract_metatypes: bool,
        attributes: dict[str, str],
    ) -> list[list[str]]:
        data = ModelParsing.extract_elements(
            model,
            parent_element_name=root_package,
            metatype=metatype,
            ignore_abstract_metatypes=ignore_abstract_metatypes,
        )

        attr_dict = ModelParsing.extract_attributes(model, data, attributes)

        return list(attr_dict.values())

    @staticmethod
    def build_traceability_matrix(
        model: syside.Model,
        row_package: str,
        row_metatype: str,
        col_package: str,
        col_metatype: str,
        ignore_abstract_metatypes: bool = True,
    ) -> list[list[str | None]]:
        row_elements = ModelParsing.extract_elements(
            model,
            parent_element_name=row_package,
            metatype=row_metatype,
            ignore_abstract_metatypes=ignore_abstract_metatypes,
        )
        col_elements = ModelParsing.extract_elements(
            model,
            parent_element_name=col_package,
            metatype=col_metatype,
            ignore_abstract_metatypes=ignore_abstract_metatypes,
        )

        row_allocations: dict[syside.Element, list[syside.AllocationUsage]] = {}
        for row_element in row_elements:
            row_allocations[row_element] = (
                ModelParsing.get_owned_of_type_recursive(
                    row_element,
                    syside.AllocationUsage,  # type: ignore
                )
            )

        col_allocations: dict[syside.Element, list[syside.AllocationUsage]] = {}
        for col_element in col_elements:
            col_allocations[col_element] = (
                ModelParsing.get_owned_of_type_recursive(
                    col_element,
                    syside.AllocationUsage,  # type: ignore
                )
            )

        print(
            f"Building traceability matrix... {len(row_allocations) + len(col_allocations)} allocations"
        )
        column_headers: list[str | None] = cast(list[str | None], [""]) + [
            elem.name for elem in col_elements
        ]

        data_rows: list[list[str | None]] = []
        for row_element in row_elements:
            row_data: list[str | None] = [row_element.name]

            for col_element in col_elements:
                row_allocators = ModelParsing.get_allocations(
                    col_element, row_allocations[row_element]
                )
                col_allocators = ModelParsing.get_allocations(
                    row_element, col_allocations[col_element]
                )

                if len(col_allocators) > 0:
                    row_data.append("X")
                elif len(row_allocators) > 0:
                    row_data.append("⏎")
                else:
                    row_data.append("")

            data_rows.append(row_data)

        return [column_headers] + data_rows

    @staticmethod
    def repeat_for_each_item(
        data_source: list[str],
        sections: list[dict[str, Any]],
        page_break_between_rows: bool = False,
    ) -> str:
        html: list[str] = []

        for id, row in enumerate(data_source):
            for section in sections:
                if isinstance(section["value"], str) and re.search(
                    r"row\[", section["value"]
                ):
                    namespace = {
                        name: getattr(Exposed, name)
                        for name in dir(Exposed)
                        if not name.startswith("_")
                    }
                    func = eval(f"lambda row: {section['value']}", namespace)
                    section_value = func(row)
                else:
                    section_value = section["value"]

                match section["type"]:
                    case "h1" | "h2" | "h3" | "h4" | "h5" | "h6":
                        if len(section_value) > 0:
                            html.append(
                                f"<{section['type']}>{section_value}</{section['type']}>"
                            )
                    case "body" | "table":
                        html.append(section_value)
                    case "image":
                        html.append(f'<img src="{section_value}" />')

            if page_break_between_rows and id < len(data_source) - 1:
                html.append(Exposed.page_break())

        return "\n".join(html)

    @staticmethod
    def headless_table(
        data: list[list[str]],
        flipRowsAndCols: bool = False,
        widths: list[str] | None = None,
        css_class: str | None = None,
        col_alignment: list[str] | None = None,
    ) -> str:
        return Exposed.generic_table(
            data[0], data[1:], flipRowsAndCols, widths, css_class, col_alignment
        )

    @staticmethod
    def generic_table(
        columns: list[str],
        rows: list[list[str]],
        flipRowsAndCols: bool = False,
        widths: list[str] | None = None,
        css_class: str | None = None,
        col_alignment: list[str] | None = None,
    ) -> str:
        if rows:
            col_tags = ""
            if widths:
                col_tags = "\n".join(
                    [f'<col style="width: {w}">' for w in widths]
                )

            if flipRowsAndCols:
                matrix = [columns] + rows
                transposed = [
                    [matrix[j][i] for j in range(len(matrix))]
                    for i in range(len(matrix[0]))
                ]
                columns = transposed[0]
                rows = transposed[1:]

            class_attr = f' class="{css_class}"' if css_class else ""

            header_cells: list[str] = []
            for i, col in enumerate(columns):
                align_style = (
                    f' style="text-align: {col_alignment[i]}"'
                    if col_alignment and i < len(col_alignment)
                    else ""
                )
                header_cells.append(f"<th{align_style}><div>{col}</div></th>")
            header = "<tr>" + "".join(header_cells) + "</tr>"

            table_rows: list[str] = []
            for row in rows:
                row_cells: list[str] = []
                for i, cell in enumerate(row):
                    align_style = (
                        f' style="text-align: {col_alignment[i]}"'
                        if col_alignment and i < len(col_alignment)
                        else ""
                    )
                    row_cells.append(f"<td{align_style}>{cell}</td>")
                table_rows.append("<tr>" + "".join(row_cells) + "</tr>")

            table = (
                f"<table{class_attr}>\n{col_tags}\n<thead>\n{header}\n</thead>\n"
                + "\n".join(table_rows)
                + "\n</table>"
            )

            return table

        return ""

    @staticmethod
    def list_items(
        data: list[list[str]] | None = None,
        format: str = "- {0}",
    ) -> str:
        """
        Build a formatted list from data rows using a customizable format string.

        Examples:
            format="- **{0}**: {1}"             → - **ABC-01**: Description
            format="- **{0}**: [{1}]({2})"      → - **ABC-01**: [Description](URL)
            format="- {0} ({1})"                → - ABC-01 (Description)
        """

        if not data:
            return ""

        items: list[str] = []
        for row in data:
            try:
                items.append(format.format(*row))
            except IndexError:
                # Skip rows that don't have enough columns for the format
                continue

        return "\n".join(items)

    @staticmethod
    def list_to_text(
        data: list[list[str]] | None = None,
        format: str = "{0}",
        rows: list[str] = ["0"],
    ) -> str:
        if not data:
            return ""

        items: list[str] = []
        for id, row in enumerate(data):
            if str(id) in rows:
                try:
                    items.append(format.format(*row))
                except IndexError:
                    continue

        return "\n".join(items)

    @staticmethod
    def revision_history() -> str:
        """Generate revision history summary table"""

        # Load from versions.json
        versions_data = VersionManager.load_versions()

        if not versions_data["revisions"]:
            return "No revision history available."

        rows: list[list[str]] = []
        # Newest first
        for rev in reversed(versions_data["revisions"]):
            rows.append(
                [
                    rev["version"],
                    rev.get("date", ""),
                    rev.get("description", ""),
                    rev.get("changes", ""),
                ]
            )

        return Exposed.generic_table(
            columns=["Version", "Date", "Description", "Changes"],
            rows=rows,
            css_class="revision-history",
        )

    @staticmethod
    def changelog(
        update_changelog: bool,
        root_package: str = "Algorithmic Trading Requirements",
    ) -> str:
        """Generate detailed changelog comparing current version vs last git tag"""

        if not update_changelog:
            return ""

        latest_tag = VersionManager.get_latest_git_tag()

        if not latest_tag:
            print(
                "No git tags found. Create a tag to enable changelog generation."
            )
            return ""

        current_model = RuntimeCache.modelRef
        tagged_model = VersionManager.load_model_from_tag(latest_tag)

        if not tagged_model:
            print(f"Error: Could not load model from tag {latest_tag}")
            return ""

        old_reqs = VersionManager.extract_requirements_dict(
            tagged_model, root_package
        )
        new_reqs = VersionManager.extract_requirements_dict(
            current_model, root_package
        )

        # Compute changes
        added = set(new_reqs.keys()) - set(old_reqs.keys())
        deleted = set(old_reqs.keys()) - set(new_reqs.keys())
        common = set(old_reqs.keys()) & set(new_reqs.keys())

        # Attribute comparison for modified
        modified: dict[str, list[tuple[str, str, str]]] = {}
        for req_id in common:
            if old_reqs[req_id]["hash"] != new_reqs[req_id]["hash"]:
                old_attr = old_reqs[req_id].get("attributes", "")
                new_attr = new_reqs[req_id].get("attributes", "")

                modified[req_id] = [
                    (key, old_attr[key], new_attr[key])
                    for key in old_attr
                    if old_attr[key] != new_attr[key]
                ]

        html = ['<div class="changelog-section">']
        print("Generating changelog...")

        if not added and not modified and not deleted:
            html.append("\nNo changes.</div>")
            return "\n".join(html)

        if added:
            html.append(f"<h3>Added Requirements ({len(added)})</h3>")

            for req_id in sorted(added):
                print(f"  └ {req_id}: Added")
                html.append(f"{req_id}")

        if modified:
            html.append(f"<h3>Modified Requirements ({len(modified)})</h3>")
            for req_id, changes in modified.items():
                print(f"  └ {req_id}: Modified")
                html.append(f"{html_lib.escape(req_id)}")
                html.append("<table border='1'>")
                html.append(
                    "<tr><th>Attribute</th><th>Old</th><th>New</th></tr>"
                )
                for change in changes:
                    html.append(
                        f"<tr><td>{change[0]}</td><td>{change[1]}</td><td>{change[2]}</td></tr>"
                    )
                html.append("</table>")

        if deleted:
            html.append(f"<h3>Deleted Requirements ({len(deleted)})</h3>")

            for req_id in sorted(deleted):
                print(f"  └ {req_id}: Removed")
                html.append(f"{req_id}")

        html.append("</div>")
        return "\n".join(html)

    @staticmethod
    def unique_headers_from_qualified_name(qualified_name: str) -> str:
        header = qualified_name.split("::")[-1].strip("'\"")
        if header not in Exposed._unique_headers:
            Exposed._unique_headers.add(header)
            return header
        return ""


## ============================================================================
## MAIN GENERATION FUNCTION
## ============================================================================

if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="Generate requirement document PDF"
    )
    parser.add_argument("-o", "--output", help="Output folder path")
    parser.add_argument(
        "--project_path",
        default=str(MODELS_DIR),
        help="Path to Software Requirements sysml project",
    )
    parser.add_argument(
        "-c",
        "--update-version",
        action="store_true",
        help="Update version.json and changelog",
    )

    args = parser.parse_args()

    # Load model
    print("\n[LIVE] ===========================================")
    model: syside.Model = ModelParsing.load_model_from_project(
        args.project_path
    )  # type: ignore
    RuntimeCache.updateModelRef(model)

    update_changelog = args.update_version
    if update_changelog:
        VersionManager.update_version_history(model, PATH_SRS_TEMPLATE)

    # Generate documents (requires output path)
    if not args.output:
        parser.error("--output is required for document generation")

    # Create output directory
    output_dir = Path(args.output)
    if not output_dir.exists():
        output_dir.mkdir(parents=True, exist_ok=True)

    html, metadata = Converters.markdown_to_html(
        PATH_SRS_TEMPLATE, model, update_changelog
    )
    css = Formatting.combine_css_styles(metadata, output_dir)

    print("Generating output files...")
    Converters.html_to_file(html, output_dir / "requirements.html", css)
    Converters.html_to_docx(
        html, output_dir / "requirements.docx", PATH_WORD_TEMPLATE
    )
    Converters.html_to_pdf(html, output_dir / "requirements.pdf", css)
    print("Done.")
    exit(0)
