# -*- coding: utf-8 -*-
r"""
Create process-topology diagrams from a Carbatpy configuration.

Outputs
-------
PyVis
    Interactive HTML visualization. Labels, fluids, components and
    state nodes can be shown or hidden interactively.

Graphviz
    Static PDF, SVG and/or PNG visualization.

Python dependencies
-------------------
Install the required Python packages with pip:

    python -m pip install pyvis graphviz

Graphviz system installation
----------------------------
The Python package ``graphviz`` is only an interface to the external
Graphviz software. The Graphviz application must therefore also be
installed separately.

Download Graphviz from:

    https://graphviz.org/download/

On Windows, install Graphviz and ensure that its ``bin`` directory is
available through the PATH environment variable. A typical location is:

    C:\Program Files\Graphviz\bin

After installation, verify it in a new terminal:

    dot -V

Notes
-----
PyVis is used for interactive browser-based visualization. Graphviz is
used for static, publication-oriented output. SVG and PDF are preferable
for publications because they preserve vector graphics.

The visualization settings used during generation are reproducible.
Changes made interactively in the HTML file are not transferred back to
Python or Graphviz.
"""

from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import colorsys
import hashlib
import html
import json
import re
import webbrowser

import carbatpy as cb
from carbatpy.utils.io_utils import read_config


# ============================================================
# User settings
# ============================================================

INPUT_SOURCE = (
    Path(cb.CB_DEFAULTS["General"]["CB_DATA"])
    / "io_cycle_data"
    / "io-orc-data.yaml"
)

OUTPUT_DIRECTORY = Path(
    cb.CB_DEFAULTS["General"]["RES_DIR"]
)


VISUALIZATION_SOURCE = (
    Path(cb.CB_DEFAULTS["General"]["CB_DATA"])
    / "topology-vis-demo.yaml"
)

# None:
# The file name is derived from process.name.
#
# String:
# An explicit name can be used, for example:
# OUTPUT_STEM = "my_orc"
OUTPUT_STEM = None

WRITE_PYVIS_HTML = True
OPEN_HTML_IN_BROWSER = True

WRITE_GRAPHVIZ = True
GRAPHVIZ_FORMATS = ("pdf", "svg", "png")
WRITE_DOT_FILE = True


# ============================================================
# Visualization profiles
# ============================================================

VISUALIZATION_PROFILES = {
    "compact": {
        "show": {
            "component_nodes": True,
            "state_nodes": True,
            "edges": True,
            "edge_labels": False,

            "component_name": True,
            "component_model": False,
            "component_calc_type": False,
            "component_fixed": False,
            "component_cost_name": False,

            "state_fluid": True,
            "state_reference": True,
            "state_species": False,
            "state_temperature": False,
            "state_pressure": False,

            "edge_fluid": False,
            "edge_pressure": False,
            "edge_relation": False,
        },
        "style": {
            "component_font_size": 16,
            "state_font_size": 14,
            "edge_font_size": 11,
            "edge_width": 2.2,
            "graphviz_rankdir": "LR",
            "graphviz_dpi": 200,
        },
        "live": {
            "enabled": True,
            "show_vis_configurator": True,
        },
        "visibility": {
            "fluids": {},
            "components": {},
        },
    },

    "standard": {
        "show": {
            "component_nodes": True,
            "state_nodes": True,
            "edges": True,
            "edge_labels": True,

            "component_name": True,
            "component_model": True,
            "component_calc_type": True,
            "component_fixed": True,
            "component_cost_name": False,

            "state_fluid": True,
            "state_reference": True,
            "state_species": False,
            "state_temperature": True,
            "state_pressure": True,

            "edge_fluid": True,
            "edge_pressure": False,
            "edge_relation": False,
        },
        "style": {
            "component_font_size": 15,
            "state_font_size": 13,
            "edge_font_size": 10,
            "edge_width": 2.3,
            "graphviz_rankdir": "LR",
            "graphviz_dpi": 250,
        },
        "live": {
            "enabled": True,
            "show_vis_configurator": True,
        },
        "visibility": {
            "fluids": {},
            "components": {},
        },
    },

    "full": {
        "show": {
            "component_nodes": True,
            "state_nodes": True,
            "edges": True,
            "edge_labels": True,

            "component_name": True,
            "component_model": True,
            "component_calc_type": True,
            "component_fixed": True,
            "component_cost_name": True,

            "state_fluid": True,
            "state_reference": True,
            "state_species": True,
            "state_temperature": True,
            "state_pressure": True,

            "edge_fluid": True,
            "edge_pressure": True,
            "edge_relation": True,
        },
        "style": {
            "component_font_size": 14,
            "state_font_size": 12,
            "edge_font_size": 9,
            "edge_width": 2.3,
            "graphviz_rankdir": "LR",
            "graphviz_dpi": 300,
        },
        "live": {
            "enabled": True,
            "show_vis_configurator": True,
        },
        "visibility": {
            "fluids": {},
            "components": {},
        },
    },
}


# This dictionary overrides the selected profile.
#
# It may also be placed in the YAML/JSON input as the top-level entry
# "visualization". Function arguments have the highest priority.
VISUALIZATION = {
    "profile": "standard",

    # Examples:
    #
    # "show": {
    #     "component_cost_name": False,
    #     "edge_pressure": False,
    #     "state_pressure": True,
    # },
    #
    # "visibility": {
    #     "fluids": {
    #         "working_fluid": True,
    #         "hot_storage": True,
    #         "cold_storage": False,
    #     },
    #     "components": {
    #         "compressor": True,
    #         "start": True,
    #     },
    # },
}


# ============================================================
# Internal graph representation
# ============================================================

@dataclass
class NodeSpec:
    """Renderer-independent node description."""

    node_id: str
    kind: str
    parts: dict[str, str]
    color: str
    title: str = ""
    component: str | None = None
    fluid: str | None = None


@dataclass
class EdgeSpec:
    """Renderer-independent edge description."""

    source: str
    target: str
    fluid: str
    parts: dict[str, str]
    color: str
    title: str = ""
    dashed: bool = False


@dataclass
class TopologyGraph:
    """Common topology used by PyVis and Graphviz."""

    name: str
    nodes: dict[str, NodeSpec] = field(default_factory=dict)
    edges: list[EdgeSpec] = field(default_factory=list)

    _edge_keys: set[tuple[str, str, str, str]] = field(
        default_factory=set,
        repr=False,
    )

    def add_node(self, node: NodeSpec) -> None:
        self.nodes.setdefault(node.node_id, node)

    def add_edge(self, edge: EdgeSpec) -> None:
        relation = edge.parts.get("edge_relation", "")

        key = (
            edge.source,
            edge.target,
            edge.fluid,
            relation,
        )

        if key in self._edge_keys:
            return

        self._edge_keys.add(key)
        self.edges.append(edge)


# ============================================================
# Configuration helpers
# ============================================================

def deep_update(
    original: dict,
    updates: dict,
) -> dict:
    """Recursively merge two dictionaries."""
    for key, value in updates.items():
        if (
            isinstance(value, dict)
            and isinstance(original.get(key), dict)
        ):
            deep_update(original[key], value)
        else:
            original[key] = deepcopy(value)

    return original


def load_carbatpy_config(
    config_source: dict | str | Path,
) -> dict:
    """Read a dictionary or a YAML/JSON Carbatpy input file."""
    if isinstance(config_source, dict):
        return config_source

    return read_config(str(config_source))


def resolve_visualization(
    config: dict,
    visualization: dict | None = None,
) -> dict:
    """
    Resolve profile, input-file settings and function settings.

    Priority, from low to high:
        selected profile
        config["visualization"]
        visualization function argument
    """
    file_settings = config.get("visualization", {})

    if not isinstance(file_settings, dict):
        file_settings = {}

    argument_settings = visualization or {}

    profile_name = argument_settings.get(
        "profile",
        file_settings.get(
            "profile",
            VISUALIZATION.get("profile", "standard"),
        ),
    )

    if profile_name not in VISUALIZATION_PROFILES:
        valid = ", ".join(VISUALIZATION_PROFILES)
        raise ValueError(
            f"Unknown visualization profile '{profile_name}'. "
            f"Valid profiles are: {valid}"
        )

    settings = deepcopy(
        VISUALIZATION_PROFILES[profile_name]
    )

    # Module-level overrides
    module_settings = {
        key: value
        for key, value in VISUALIZATION.items()
        if key != "profile"
    }
    deep_update(settings, module_settings)

    # Settings stored in YAML/JSON
    file_overrides = {
        key: value
        for key, value in file_settings.items()
        if key != "profile"
    }
    deep_update(settings, file_overrides)

    # Explicit function argument
    argument_overrides = {
        key: value
        for key, value in argument_settings.items()
        if key != "profile"
    }
    deep_update(settings, argument_overrides)

    settings["profile"] = profile_name
    return settings


def safe_filename(name: Any) -> str:
    """Create a safe output file name."""
    result = re.sub(
        r"[^A-Za-z0-9_.-]+",
        "_",
        str(name),
    ).strip("._")

    return result or "process"


def automatic_color(
    name: Any,
    saturation: float = 0.42,
    brightness: float = 0.90,
) -> str:
    """Generate a reproducible color from an arbitrary name."""
    digest = hashlib.sha256(
        str(name).encode("utf-8")
    ).hexdigest()

    hue = int(digest[:8], 16) / 0xFFFFFFFF

    red, green, blue = colorsys.hsv_to_rgb(
        hue,
        saturation,
        brightness,
    )

    return (
        f"#{int(red * 255):02x}"
        f"{int(green * 255):02x}"
        f"{int(blue * 255):02x}"
    )


def dictionary_as_html(data: Any) -> str:
    """Format data for a PyVis tooltip."""
    text = json.dumps(
        data,
        indent=2,
        ensure_ascii=False,
        default=str,
    )

    return f"<pre>{html.escape(text)}</pre>"


def resolve_ambient(value: Any, config: dict) -> Any:
    """Replace 'ambient' by the Carbatpy ambient temperature."""
    if value != "ambient":
        return value

    process_data = config.get("process", {})
    configured_temperature = process_data.get("temp_ambient")

    if configured_temperature is not None:
        return configured_temperature

    return cb.CB_DEFAULTS["General"]["T_SUR"]


def resolve_property(
    config: dict,
    fluid: str,
    property_reference: str | None,
) -> Any:
    """Resolve names such as temp_high or p_low."""
    if property_reference is None:
        return None

    fluid_data = config.get(fluid, {})

    if not isinstance(fluid_data, dict):
        return property_reference

    value = fluid_data.get(
        property_reference,
        property_reference,
    )

    if str(property_reference).startswith("temp"):
        value = resolve_ambient(value, config)

    return value


def format_temperature(value: Any) -> str:
    if isinstance(value, (int, float)):
        return (
            f"{value:g} K "
            f"({value - 273.15:g} °C)"
        )

    return str(value)


def format_pressure(value: Any) -> str:
    if isinstance(value, (int, float)):
        return (
            f"{value:g} Pa "
            f"({value / 1.0e5:g} bar)"
        )

    return str(value)


def item_is_enabled(
    visibility: Any,
    name: str | None,
) -> bool:
    """
    Interpret a fluid/component visibility setting.

    Supported forms
    ---------------
    {}
        All items are visible.

    {"working_fluid": True, "cold_storage": False}
        Item-specific selection.

    ["working_fluid", "hot_storage"]
        Only listed items are visible.

    True or False
        Enable or disable everything.
    """
    if name is None:
        return True

    if visibility is None:
        return True

    if isinstance(visibility, bool):
        return visibility

    if isinstance(visibility, (list, tuple, set)):
        return name in visibility

    if isinstance(visibility, dict):
        return bool(
            visibility.get(
                name,
                visibility.get("*", True),
            )
        )

    return True


# ============================================================
# Label construction
# ============================================================

def visible_node_label(
    node: NodeSpec,
    settings: dict,
) -> str:
    """Construct a node label from the selected information."""
    show = settings["show"]

    lines = [
        text
        for key, text in node.parts.items()
        if show.get(key, False) and text
    ]

    return "\n".join(lines)


def visible_edge_label(
    edge: EdgeSpec,
    settings: dict,
) -> str:
    """Construct an edge label from the selected information."""
    show = settings["show"]

    if not show.get("edge_labels", True):
        return ""

    lines = [
        text
        for key, text in edge.parts.items()
        if show.get(key, False) and text
    ]

    return "\n".join(lines)


def node_is_visible(
    node: NodeSpec,
    settings: dict,
) -> bool:
    show = settings["show"]
    visibility = settings.get("visibility", {})

    if node.kind == "component":
        if not show.get("component_nodes", True):
            return False

        return item_is_enabled(
            visibility.get("components", {}),
            node.component,
        )

    if node.kind == "state":
        if not show.get("state_nodes", True):
            return False

        return item_is_enabled(
            visibility.get("fluids", {}),
            node.fluid,
        )

    return True


def edge_is_visible(
    edge: EdgeSpec,
    graph: TopologyGraph,
    settings: dict,
) -> bool:
    if not settings["show"].get("edges", True):
        return False

    visibility = settings.get("visibility", {})

    if not item_is_enabled(
        visibility.get("fluids", {}),
        edge.fluid,
    ):
        return False

    source = graph.nodes.get(edge.source)
    target = graph.nodes.get(edge.target)

    return (
        source is not None
        and target is not None
        and node_is_visible(source, settings)
        and node_is_visible(target, settings)
    )


# ============================================================
# Carbatpy topology interpretation
# ============================================================

def find_components(config: dict) -> dict[str, dict]:
    """Find top-level dictionaries describing components."""
    return {
        name: values
        for name, values in config.items()
        if (
            isinstance(values, dict)
            and "model" in values
        )
    }


def find_cycle_fluids(
    config: dict,
    components: dict[str, dict],
    cycle: list[str],
) -> set[str]:
    """Determine the fluid or fluids in the principal cycle."""
    cycle_fluids: set[str] = set()

    fluids_all = config.get("fluids_all", {})

    if isinstance(fluids_all, dict):
        for fluid, fluid_type in fluids_all.items():
            if str(fluid_type).lower() == "cycle":
                cycle_fluids.add(fluid)

    if cycle_fluids or not cycle:
        return cycle_fluids

    species_sets = []
    processed = set()

    for component_name in cycle:
        if component_name in processed:
            continue

        processed.add(component_name)
        component = components.get(component_name)

        if component is None:
            continue

        species = component.get("species", {})

        if isinstance(species, dict) and species:
            species_sets.append(set(species))

    if species_sets:
        cycle_fluids = set.intersection(*species_sets)

    return cycle_fluids


def pressure_reference_for_cycle_edge(
    components: dict[str, dict],
    source: str,
    target: str,
    fluid: str,
) -> str | None:
    """Determine the pressure reference of a cycle edge."""
    source_flow = (
        components
        .get(source, {})
        .get("species", {})
        .get(fluid, {})
    )

    if isinstance(source_flow, dict):
        pressure = source_flow.get("p_out")

        if pressure is not None:
            return pressure

    target_flow = (
        components
        .get(target, {})
        .get("species", {})
        .get(fluid, {})
    )

    if isinstance(target_flow, dict):
        return target_flow.get("p_in")

    return None


def component_fixed_text(
    component_name: str,
    component_data: dict,
    process_data: dict,
) -> str:
    """Collect locally and globally fixed quantities."""
    fixed_lines = []
    local_fixed = component_data.get("fixed")

    if isinstance(local_fixed, str):
        if local_fixed in component_data:
            fixed_lines.append(
                f"{local_fixed} = "
                f"{component_data[local_fixed]}"
            )
        else:
            fixed_lines.append(local_fixed)

    elif isinstance(local_fixed, dict):
        fixed_lines.extend(
            f"{key} = {value}"
            for key, value in local_fixed.items()
        )

    elif local_fixed is not None:
        fixed_lines.append(str(local_fixed))

    process_fixed = process_data.get("fixed", {})

    if isinstance(process_fixed, dict):
        values = process_fixed.get(component_name, {})

        if isinstance(values, dict):
            fixed_lines.extend(
                f"{key} = {value}"
                for key, value in values.items()
            )

    if not fixed_lines:
        return ""

    return "fixed: " + ", ".join(fixed_lines)


def construct_topology(
    config_source: dict | str | Path,
) -> tuple[TopologyGraph, dict]:
    """Construct a renderer-independent process topology."""
    config = load_carbatpy_config(config_source)

    process_data = config.get("process", {})
    process_name = process_data.get("name", "process")
    cycle = process_data.get("cycle", [])

    if not isinstance(cycle, list):
        cycle = list(cycle)

    components = find_components(config)
    graph = TopologyGraph(name=str(process_name))

    # --------------------------------------------------------
    # Components
    # --------------------------------------------------------

    for component_name, component_data in components.items():
        model = component_data.get("model", "Unknown")
        calc_type = component_data.get("calc_type")
        cost_name = component_data.get("name_cost")

        parts = {
            "component_name": str(component_name),
            "component_model": f"[{model}]",
            "component_calc_type": (
                f"calc: {calc_type}"
                if calc_type is not None
                else ""
            ),
            "component_fixed": component_fixed_text(
                component_name,
                component_data,
                process_data,
            ),
            "component_cost_name": (
                str(cost_name)
                if cost_name is not None
                else ""
            ),
        }

        graph.add_node(
            NodeSpec(
                node_id=f"component::{component_name}",
                kind="component",
                component=component_name,
                fluid=None,
                parts=parts,
                color=automatic_color(f"model::{model}"),
                title=dictionary_as_html(component_data),
            )
        )

    # --------------------------------------------------------
    # State nodes
    # --------------------------------------------------------

    def add_state_node(
        fluid: str,
        state_reference: str,
        pressure_reference: str | None,
    ) -> str:
        node_id = (
            f"state::{fluid}::{state_reference}::"
            f"{pressure_reference or 'no_pressure'}"
        )

        if node_id in graph.nodes:
            return node_id

        state_value = resolve_property(
            config,
            fluid,
            state_reference,
        )

        pressure_value = resolve_property(
            config,
            fluid,
            pressure_reference,
        )

        fluid_data = config.get(fluid, {})

        if not isinstance(fluid_data, dict):
            fluid_data = {}

        physical_species = fluid_data.get("species", fluid)

        parts = {
            "state_fluid": str(fluid),
            "state_reference": str(state_reference),
            "state_species": f"species: {physical_species}",
            "state_temperature": (
                f"T = {format_temperature(state_value)}"
                if str(state_reference).startswith("temp")
                else f"value = {state_value}"
            ),
            "state_pressure": (
                f"p = {format_pressure(pressure_value)}"
                if pressure_reference is not None
                else ""
            ),
        }

        tooltip = {
            "fluid_name": fluid,
            "species": physical_species,
            "state_reference": state_reference,
            "state_value": state_value,
            "pressure_reference": pressure_reference,
            "pressure_value": pressure_value,
            "fluid_configuration": fluid_data,
        }

        graph.add_node(
            NodeSpec(
                node_id=node_id,
                kind="state",
                component=None,
                fluid=fluid,
                parts=parts,
                color=automatic_color(f"state::{fluid}"),
                title=dictionary_as_html(tooltip),
            )
        )

        return node_id

    def resolve_reference(
        fluid: str,
        reference: str,
        pressure_reference: str | None,
    ) -> str:
        if reference in components:
            return f"component::{reference}"

        return add_state_node(
            fluid,
            reference,
            pressure_reference,
        )

    # --------------------------------------------------------
    # Edges
    # --------------------------------------------------------

    def add_process_edge(
        source_id: str,
        target_id: str,
        fluid: str,
        pressure_reference: str | None = None,
        relation: str | None = None,
        dashed: bool = False,
        edge_id: str | None = None,
    ) -> None:
        pressure_value = resolve_property(
            config,
            fluid,
            pressure_reference,
        )
    
        fluid_data = config.get(fluid, {})
    
        if not isinstance(fluid_data, dict):
            fluid_data = {}
    
        parts = {
            "edge_fluid": str(fluid),
            "edge_pressure": (
                f"{pressure_reference}: "
                f"{format_pressure(pressure_value)}"
                if pressure_reference is not None
                else ""
            ),
            "edge_relation": (
                str(relation)
                if relation is not None
                else ""
            ),
        }
    
        tooltip = {
            "fluid": fluid,
            "species": fluid_data.get("species", fluid),
            "pressure_reference": pressure_reference,
            "pressure_value": pressure_value,
            "relation": relation,
            "fluid_configuration": fluid_data,
        }
    
        graph.add_edge(
            EdgeSpec(
                source=source_id,
                target=target_id,
                fluid=fluid,
                parts=parts,
                color=automatic_color(f"fluid::{fluid}"),
                title=dictionary_as_html(tooltip),
                dashed=dashed,
            )
        )

    # --------------------------------------------------------
    # Explicit process cycle
    # --------------------------------------------------------

    cycle_fluids = find_cycle_fluids(
        config,
        components,
        cycle,
    )

    if cycle:
        for fluid in cycle_fluids:
            for source, target in zip(cycle[:-1], cycle[1:]):
                if (
                    source not in components
                    or target not in components
                ):
                    continue

                pressure_reference = (
                    pressure_reference_for_cycle_edge(
                        components,
                        source,
                        target,
                        fluid,
                    )
                )

                add_process_edge(
                    source_id=f"component::{source}",
                    target_id=f"component::{target}",
                    fluid=fluid,
                    pressure_reference=pressure_reference,
                    relation="cycle",
                )

    # --------------------------------------------------------
    # Additional streams from species -> in/out
    # --------------------------------------------------------

    for component_name, component_data in components.items():
        model = component_data.get("model")
        species_data = component_data.get("species", {})

        if not isinstance(species_data, dict):
            continue

        for fluid, flow_data in species_data.items():
            if not isinstance(flow_data, dict):
                continue

            input_reference = flow_data.get("in")
            output_reference = flow_data.get("out")
            pressure_in = flow_data.get("p_in")
            pressure_out = flow_data.get("p_out")

            component_id = f"component::{component_name}"

            # The explicit process.cycle determines component-to-
            # component connections for the main working fluid.
            # The prescribed initial state is nevertheless shown.
            if cycle and fluid in cycle_fluids:
                if (
                    model == "Start"
                    and input_reference is not None
                    and input_reference not in components
                ):
                    state_id = add_state_node(
                        fluid,
                        input_reference,
                        pressure_in,
                    )

                    add_process_edge(
                        source_id=state_id,
                        target_id=component_id,
                        fluid=fluid,
                        pressure_reference=pressure_in,
                        relation="initial state",
                        dashed=True,
                    )

                continue

            # Start component with prescribed identical inlet/outlet.
            if (
                model == "Start"
                and input_reference is not None
                and input_reference == output_reference
                and input_reference not in components
            ):
                state_id = add_state_node(
                    fluid,
                    input_reference,
                    pressure_in,
                )

                add_process_edge(
                    source_id=state_id,
                    target_id=component_id,
                    fluid=fluid,
                    pressure_reference=pressure_in,
                    relation="initial state",
                    dashed=True,
                )
                continue

            if input_reference is not None:
                source_id = resolve_reference(
                    fluid,
                    input_reference,
                    pressure_in,
                )

                add_process_edge(
                    source_id=source_id,
                    target_id=component_id,
                    fluid=fluid,
                    pressure_reference=pressure_in,
                    relation="in",
                )

            if output_reference is not None:
                target_id = resolve_reference(
                    fluid,
                    output_reference,
                    pressure_out,
                )

                add_process_edge(
                    source_id=component_id,
                    target_id=target_id,
                    fluid=fluid,
                    pressure_reference=pressure_out,
                    relation="out",
                )

    return graph, config


# ============================================================
# Interactive HTML controls
# ============================================================

LIVE_LABELS = {
    "component_nodes": "Komponenten anzeigen",
    "state_nodes": "Zustände anzeigen",
    "edges": "Kanten anzeigen",
    "edge_labels": "Kantenbeschriftungen",

    "component_name": "Komponentenname",
    "component_model": "Komponentenmodell",
    "component_calc_type": "Berechnungstyp",
    "component_fixed": "Festgelegte Größen",
    "component_cost_name": "Kostenmodell/-name",

    "state_fluid": "Fluidname am Zustand",
    "state_reference": "Zustandsreferenz",
    "state_species": "Stoffspezies",
    "state_temperature": "Temperatur",
    "state_pressure": "Druck am Zustand",

    "edge_fluid": "Fluid auf Kante",
    "edge_pressure": "Druck auf Kante",
    "edge_relation": "Relation auf Kante",
}


def _checkbox(
    key: str,
    label: str,
    checked: bool,
    css_class: str,
) -> str:
    checked_text = " checked" if checked else ""

    return (
        f'<label class="cbtop-checkbox">'
        f'<input type="checkbox" '
        f'class="{css_class}" '
        f'data-key="{html.escape(key)}"'
        f'{checked_text}> '
        f'{html.escape(label)}'
        f'</label>'
    )


def create_live_controls(
    graph: TopologyGraph,
    settings: dict,
) -> str:
    """
    Create an HTML/JavaScript panel for changing labels and visibility.
    """
    show = settings["show"]
    visibility = settings.get("visibility", {})

    component_names = sorted({
        node.component
        for node in graph.nodes.values()
        if node.component is not None
    })

    fluid_names = sorted({
        edge.fluid
        for edge in graph.edges
    } | {
        node.fluid
        for node in graph.nodes.values()
        if node.fluid is not None
    })

    general_keys = [
        "component_nodes",
        "state_nodes",
        "edges",
        "edge_labels",
    ]

    component_keys = [
        "component_name",
        "component_model",
        "component_calc_type",
        "component_fixed",
        "component_cost_name",
    ]

    state_keys = [
        "state_fluid",
        "state_reference",
        "state_species",
        "state_temperature",
        "state_pressure",
    ]

    edge_keys = [
        "edge_fluid",
        "edge_pressure",
        "edge_relation",
    ]

    def checkbox_group(keys: list[str]) -> str:
        return "\n".join(
            _checkbox(
                key,
                LIVE_LABELS[key],
                bool(show.get(key, False)),
                "cbtop-show",
            )
            for key in keys
        )

    fluid_controls = "\n".join(
        _checkbox(
            fluid,
            fluid,
            item_is_enabled(
                visibility.get("fluids", {}),
                fluid,
            ),
            "cbtop-fluid",
        )
        for fluid in fluid_names
    )

    component_controls = "\n".join(
        _checkbox(
            component,
            component,
            item_is_enabled(
                visibility.get("components", {}),
                component,
            ),
            "cbtop-component",
        )
        for component in component_names
    )

    node_metadata = []

    for node in graph.nodes.values():
        node_metadata.append({
            "id": node.node_id,
            "kind": node.kind,
            "component": node.component,
            "fluid": node.fluid,
            "parts": node.parts,
        })

    edge_metadata = []

    for number, edge in enumerate(graph.edges):
        edge_metadata.append({
            "id": f"topology-edge-{number}",
            "source": edge.source,
            "target": edge.target,
            "fluid": edge.fluid,
            "parts": edge.parts,
        })

    profile_show_settings = {
        profile_name: profile["show"]
        for profile_name, profile
        in VISUALIZATION_PROFILES.items()
    }

    metadata_json = json.dumps(
        {
            "nodes": node_metadata,
            "edges": edge_metadata,
            "profiles": profile_show_settings,
        },
        ensure_ascii=True,
    )

    return f"""
<style>
#cbtop-panel {{
    position: fixed;
    top: 10px;
    left: 10px;
    z-index: 9999;
    width: 285px;
    max-height: 92vh;
    overflow-y: auto;
    background: rgba(255, 255, 255, 0.96);
    border: 1px solid #777;
    border-radius: 6px;
    box-shadow: 0 2px 9px rgba(0, 0, 0, 0.25);
    padding: 9px 11px;
    font-family: Arial, sans-serif;
    font-size: 13px;
}}

#cbtop-panel summary {{
    cursor: pointer;
    font-weight: bold;
    margin: 4px 0;
}}

#cbtop-panel h3 {{
    font-size: 15px;
    margin: 2px 0 8px 0;
}}

.cbtop-checkbox {{
    display: block;
    padding: 1px 0;
    white-space: normal;
}}

.cbtop-buttons {{
    display: flex;
    gap: 4px;
    margin-bottom: 7px;
}}

.cbtop-buttons button {{
    flex: 1;
    cursor: pointer;
    padding: 4px;
}}

.cbtop-small {{
    color: #555;
    font-size: 11px;
    margin-top: 7px;
}}
</style>

<div id="cbtop-panel">
    <h3>Darstellung</h3>

    <div class="cbtop-buttons">
        <button type="button"
                onclick="cbtopApplyProfile('compact')">
            kompakt
        </button>
        <button type="button"
                onclick="cbtopApplyProfile('standard')">
            standard
        </button>
        <button type="button"
                onclick="cbtopApplyProfile('full')">
            vollständig
        </button>
    </div>

    <details open>
        <summary>Allgemein</summary>
        {checkbox_group(general_keys)}
    </details>

    <details>
        <summary>Komponentenbeschriftung</summary>
        {checkbox_group(component_keys)}
    </details>

    <details>
        <summary>Zustandsbeschriftung</summary>
        {checkbox_group(state_keys)}
    </details>

    <details>
        <summary>Kantenbeschriftung</summary>
        {checkbox_group(edge_keys)}
    </details>

    <details>
        <summary>Fluide</summary>
        {fluid_controls}
    </details>

    <details>
        <summary>Komponenten</summary>
        {component_controls}
    </details>

    <div class="cbtop-small">
        Layout-, Schrift- und Physikeinstellungen befinden sich
        zusätzlich im vis-network-Konfigurationsfenster.
        Änderungen gelten nur für diese Browserdarstellung.
    </div>
</div>

<script>
const cbtopMetadata = {metadata_json};

function cbtopCheckedByKey(cssClass, key, defaultValue=true) {{
    const selector =
        "." + cssClass + '[data-key="' +
        CSS.escape(String(key)) + '"]';

    const element = document.querySelector(selector);

    if (element === null) {{
        return defaultValue;
    }}

    return element.checked;
}}

function cbtopComposeLabel(parts) {{
    const lines = [];

    for (const [key, value] of Object.entries(parts)) {{
        if (
            value &&
            cbtopCheckedByKey("cbtop-show", key, false)
        ) {{
            lines.push(value);
        }}
    }}

    return lines.join("\\n");
}}

function cbtopNodeVisible(item) {{
    if (item.kind === "component") {{
        if (!cbtopCheckedByKey(
            "cbtop-show", "component_nodes", true
        )) {{
            return false;
        }}

        return cbtopCheckedByKey(
            "cbtop-component",
            item.component,
            true
        );
    }}

    if (item.kind === "state") {{
        if (!cbtopCheckedByKey(
            "cbtop-show", "state_nodes", true
        )) {{
            return false;
        }}

        return cbtopCheckedByKey(
            "cbtop-fluid",
            item.fluid,
            true
        );
    }}

    return true;
}}

function cbtopRefresh() {{
    const visibleNodes = {{}};

    for (const item of cbtopMetadata.nodes) {{
        const visible = cbtopNodeVisible(item);
        visibleNodes[item.id] = visible;

        nodes.update({{
            id: item.id,
            label: cbtopComposeLabel(item.parts),
            hidden: !visible
        }});
    }}

    const showEdges = cbtopCheckedByKey(
        "cbtop-show", "edges", true
    );

    const showEdgeLabels = cbtopCheckedByKey(
        "cbtop-show", "edge_labels", true
    );

    for (const item of cbtopMetadata.edges) {{
        const fluidVisible = cbtopCheckedByKey(
            "cbtop-fluid",
            item.fluid,
            true
        );

        const visible =
            showEdges &&
            fluidVisible &&
            visibleNodes[item.source] !== false &&
            visibleNodes[item.target] !== false;

        edges.update({{
            id: item.id,
            label: showEdgeLabels
                ? cbtopComposeLabel(item.parts)
                : "",
            hidden: !visible
        }});
    }}
}}

function cbtopApplyProfile(profileName) {{
    const profile = cbtopMetadata.profiles[profileName];

    if (!profile) {{
        return;
    }}

    document.querySelectorAll(".cbtop-show").forEach(
        function(element) {{
            const key = element.dataset.key;

            if (
                Object.prototype.hasOwnProperty.call(
                    profile, key
                )
            ) {{
                element.checked = Boolean(profile[key]);
            }}
        }}
    );

    cbtopRefresh();
}}

document.querySelectorAll(
    ".cbtop-show, .cbtop-fluid, .cbtop-component"
).forEach(function(element) {{
    element.addEventListener("change", cbtopRefresh);
}});

cbtopRefresh();
</script>
"""


# ============================================================
# PyVis renderer
# ============================================================

def write_pyvis_html(
    graph: TopologyGraph,
    output_file: str | Path,
    settings: dict,
    open_browser: bool = True,
) -> Path:
    """Write an interactive HTML visualization as UTF-8."""
    try:
        from pyvis.network import Network
    except ImportError as error:
        raise ImportError(
            "PyVis is not installed. Run:\n"
            "python -m pip install pyvis"
        ) from error

    output_path = Path(output_file).resolve()
    output_path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    style = settings["style"]
    live = settings.get("live", {})

    net = Network(
        height="850px",
        width="100%",
        directed=True,
        bgcolor="#ffffff",
        font_color="#202020",
        cdn_resources="in_line",
    )

    net.heading = graph.name

    show_configurator = bool(
        live.get("show_vis_configurator", True)
    )

    # Wichtig: show_buttons() muss vor set_options() stehen.
    # set_options() ersetzt in einigen PyVis-Versionen das
    # Options-Objekt durch ein normales Dictionary.
    if show_configurator:
        net.show_buttons(
            filter_=[
                "physics",
                "nodes",
                "edges",
                "layout",
                "interaction",
            ]
        )

    configure_enabled = (
        "true" if show_configurator else "false"
    )

    net.set_options(
        f"""
        {{
          "configure": {{
            "enabled": {configure_enabled},
            "filter": [
              "physics",
              "nodes",
              "edges",
              "layout",
              "interaction"
            ]
          }},
          "nodes": {{
            "borderWidth": 2,
            "margin": 12
          }},
          "edges": {{
            "arrows": {{
              "to": {{
                "enabled": true,
                "scaleFactor": 0.8
              }}
            }},
            "font": {{
              "size": {style["edge_font_size"]},
              "align": "middle",
              "background": "rgba(255,255,255,0.80)"
            }},
            "smooth": {{
              "enabled": true,
              "type": "dynamic"
            }}
          }},
          "physics": {{
            "enabled": true,
            "solver": "barnesHut",
            "barnesHut": {{
              "gravitationalConstant": -5000,
              "centralGravity": 0.15,
              "springLength": 190,
              "springConstant": 0.04,
              "damping": 0.25,
              "avoidOverlap": 0.4
            }},
            "stabilization": {{
              "enabled": true,
              "iterations": 1200
            }}
          }},
          "interaction": {{
            "hover": true,
            "navigationButtons": true,
            "keyboard": true,
            "multiselect": true
          }}
        }}
        """
    )


    for node in graph.nodes.values():
        font_size = (
            style["state_font_size"]
            if node.kind == "state"
            else style["component_font_size"]
        )

        net.add_node(
            node.node_id,
            label=visible_node_label(node, settings),
            title=node.title,
            shape=(
                "database"
                if node.kind == "state"
                else "box"
            ),
            hidden=not node_is_visible(node, settings),
            font={
                "size": font_size,
                "face": "Arial",
            },
            color={
                "background": node.color,
                "border": "#404040",
                "highlight": {
                    "background": node.color,
                    "border": "#000000",
                },
            },
        )

    for number, edge in enumerate(graph.edges):
        edge_id = f"edge-{number}"
        net.add_edge(
            edge.source,
            edge.target,
            id=edge_id,
            label=visible_edge_label(edge, settings),
            title=edge.title,
            color=edge.color,
            width=style["edge_width"],
            dashes=edge.dashed,
            hidden=not edge_is_visible(
                edge,
                graph,
                settings,
            ),
        )

    html_text = net.generate_html(notebook=False)

    if live.get("enabled", True):
        controls = create_live_controls(
            graph,
            settings,
        )
        html_text = html_text.replace(
            "</body>",
            controls + "\n</body>",
        )

    # Explicit UTF-8 avoids the Windows cp1252 error in
    # pyvis.Network.write_html().
    output_path.write_text(
        html_text,
        encoding="utf-8",
    )

    if open_browser:
        webbrowser.open(output_path.as_uri())

    return output_path


# ============================================================
# Graphviz renderer
# ============================================================

def create_graphviz_graph(
    graph: TopologyGraph,
    settings: dict,
):
    """Create a publication-oriented Graphviz graph."""
    try:
        from graphviz import Digraph
    except ImportError as error:
        raise ImportError(
            "The Python Graphviz interface is not installed. Run:\n"
            "python -m pip install graphviz"
        ) from error

    style = settings["style"]

    dot = Digraph(
        name=safe_filename(graph.name),
        comment=f"Topology of {graph.name}",
        encoding="utf-8",
    )

    dot.attr(
        rankdir=str(style["graphviz_rankdir"]),
        splines="spline",
        overlap="false",
        bgcolor="white",
        labelloc="t",
        label=graph.name,
        fontsize="20",
        fontname="Arial",
        pad="0.25",
        nodesep="0.45",
        ranksep="0.75",
        dpi=str(style["graphviz_dpi"]),
    )

    dot.attr(
        "node",
        style="filled,rounded",
        fontname="Arial",
        color="#404040",
        penwidth="1.2",
        margin="0.12,0.08",
    )

    dot.attr(
        "edge",
        fontname="Arial",
        arrowsize="0.8",
        penwidth=str(style["edge_width"]),
    )

    visible_nodes = {
        node_id: node
        for node_id, node in graph.nodes.items()
        if node_is_visible(node, settings)
    }

    # Graphviz-safe IDs avoid the special meaning of ":" in DOT.
    graphviz_ids = {
        original_id: f"n{number}"
        for number, original_id
        in enumerate(visible_nodes)
    }

    for original_id, node in visible_nodes.items():
        font_size = (
            style["state_font_size"]
            if node.kind == "state"
            else style["component_font_size"]
        )

        dot.node(
            graphviz_ids[original_id],
            label=visible_node_label(node, settings),
            shape=(
                "cylinder"
                if node.kind == "state"
                else "box"
            ),
            fillcolor=node.color,
            fontsize=str(font_size),
        )

    for edge in graph.edges:
        if not edge_is_visible(edge, graph, settings):
            continue

        if (
            edge.source not in graphviz_ids
            or edge.target not in graphviz_ids
        ):
            continue

        dot.edge(
            graphviz_ids[edge.source],
            graphviz_ids[edge.target],
            label=visible_edge_label(edge, settings),
            color=edge.color,
            fontcolor="#303030",
            fontsize=str(style["edge_font_size"]),
            style="dashed" if edge.dashed else "solid",
        )

    return dot


def write_graphviz_files(
    graph: TopologyGraph,
    output_base: str | Path,
    settings: dict,
    formats: tuple[str, ...] = ("pdf", "svg", "png"),
    write_dot: bool = True,
) -> list[Path]:
    """Write Graphviz DOT, PDF, SVG and/or PNG files."""
    output_base = Path(output_base).resolve()
    output_base.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    dot = create_graphviz_graph(
        graph,
        settings,
    )

    written_files: list[Path] = []

    if write_dot:
        dot_file = output_base.with_suffix(".dot")
        dot_file.write_text(
            dot.source,
            encoding="utf-8",
        )
        written_files.append(dot_file)

    for output_format in formats:
        output_format = output_format.lower().lstrip(".")
        output_file = output_base.with_suffix(
            f".{output_format}"
        )

        try:
            rendered_data = dot.pipe(
                format=output_format,
                encoding=None,
            )
            output_file.write_bytes(rendered_data)

        except Exception as error:
            raise RuntimeError(
                f"Graphviz could not create:\n"
                f"{output_file}\n\n"
                f"Original error:\n{error}"
            ) from error

        written_files.append(output_file)

    return written_files


# ============================================================
# Public combined function
# ============================================================

def build_topology(
    config_source: dict | str | Path,
    output_directory: str | Path | None = None,
    output_stem: str | None = None,
    visualization: dict | None = None,
    write_html: bool = True,
    open_html: bool = True,
    graphviz_formats: tuple[str, ...] = (),
    write_dot: bool = False,
) -> TopologyGraph:
    """
    Construct a topology and generate all requested outputs.

    Parameters
    ----------
    config_source
        Existing dictionary or path to a YAML/JSON input file.

    output_directory
        Destination directory. The Carbatpy result directory is used
        by default.

    output_stem
        File name without extension. If None, process.name is used.

    visualization
        Optional visualization settings overriding the selected profile.

    write_html
        Generate the interactive PyVis HTML file.

    open_html
        Open the HTML file in the default browser.

    graphviz_formats
        Static formats such as ("pdf", "svg", "png").

    write_dot
        Save the Graphviz DOT source.
    """
    graph, config = construct_topology(config_source)

    settings = resolve_visualization(
        config,
        visualization,
    )

    if output_directory is None:
        output_directory = cb.CB_DEFAULTS["General"]["RES_DIR"]

    output_directory = Path(output_directory).resolve()
    output_directory.mkdir(
        parents=True,
        exist_ok=True,
    )

    if output_stem is None:
        process_name = (
            config
            .get("process", {})
            .get("name", graph.name)
        )
        output_stem = (
            f"{safe_filename(process_name)}_topology"
        )
    else:
        output_stem = safe_filename(output_stem)

    output_base = output_directory / output_stem
    written_files: list[Path] = []

    if write_html:
        written_files.append(
            write_pyvis_html(
                graph=graph,
                output_file=output_base.with_suffix(".html"),
                settings=settings,
                open_browser=open_html,
            )
        )

    if graphviz_formats or write_dot:
        written_files.extend(
            write_graphviz_files(
                graph=graph,
                output_base=output_base,
                settings=settings,
                formats=graphviz_formats,
                write_dot=write_dot,
            )
        )

    print(
        f"\nVisualization profile: "
        f"{settings['profile']}"
    )
    print("Generated topology files:")

    for file_path in written_files:
        print(f"  {file_path}")

    return graph


# ============================================================
# Script execution
# ============================================================

if __name__ == "__main__":
    visualization_settings = read_config(
                                str(VISUALIZATION_SOURCE)
                            )
    topology = build_topology(
        config_source=INPUT_SOURCE,
        output_directory=OUTPUT_DIRECTORY,
        output_stem=OUTPUT_STEM,
        visualization=visualization_settings,
        write_html=WRITE_PYVIS_HTML,
        open_html=OPEN_HTML_IN_BROWSER,
        graphviz_formats=(
            GRAPHVIZ_FORMATS
            if WRITE_GRAPHVIZ
            else ()
        ),
        write_dot=(
            WRITE_DOT_FILE
            if WRITE_GRAPHVIZ
            else False
        ),
    )