# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier:  MIT

import argparse
import textwrap
from pathlib import Path
from typing import Any, Optional, Union

import numpy as np
import plotext as plt
import plotly.graph_objects as go
from dash import dcc, html
from plotly.subplots import make_subplots

from utils.logger import (
    console_debug,
    console_error,
    console_log,
    console_warning,
    demarcate,
)
from utils.roofline_calc import (
    MATRIX_DATATYPES,
    PEAK_OPS_DATATYPES,
    SUPPORTED_DATATYPES,
    construct_roof,
)
from utils.specs import MachineSpecs

SYMBOLS = [0, 1, 2, 3, 4, 5, 13, 17, 18, 20]


def wrap_text(text: str, width: int = 100) -> str:
    """
    Wraps text using textwrap and joins lines with <br> for Plotly.
    """
    if not isinstance(text, str):
        text = str(text)
    wrapped_lines = textwrap.wrap(
        text, width=width, break_long_words=True, replace_whitespace=False
    )
    return "<br>".join(wrapped_lines)


def to_int(value: Union[float, None]) -> Union[int, float]:
    if value is None:
        return np.nan
    return int(value)


class Roofline:
    def __init__(
        self,
        args: argparse.Namespace,
        mspec: MachineSpecs,
        run_parameters: dict[str, Any],
    ) -> None:
        self.__args = args
        self.__mspec = mspec
        self.__run_parameters = run_parameters
        self.__ai_data: Optional[dict[str, Any]] = None
        self.__ceiling_data: Optional[dict[str, Any]] = None
        self.__figure = go.Figure()

    def get_args(self) -> argparse.Namespace:
        return self.__args

    def roof_setup(self) -> None:
        workload_dir_val = self.__run_parameters.get("workload_dir")

        if not workload_dir_val:
            console_error(
                "Workload directory is not set. Cannot perform setup.", exit=False
            )
            return

        base_dir = str(workload_dir_val)

        base_path = Path(base_dir)

        if base_path.name == "workloads" and base_path.parent == Path.cwd():
            app_name = getattr(self.__args, "name", "default_app_name")
            gpu_model_name = getattr(self.__mspec, "gpu_model", "default_gpu_model")

            # Create the new path
            new_path = base_path / app_name / gpu_model_name

            # Update workload_dir with the new path, maintaining original data structure
            if isinstance(workload_dir_val, list):
                # Update the nested list structure
                if isinstance(workload_dir_val[0], (list, tuple)):
                    self.__run_parameters["workload_dir"][0][0] = str(new_path)
                else:
                    self.__run_parameters["workload_dir"][0] = str(new_path)
            else:
                # Update string value
                self.__run_parameters["workload_dir"] = str(new_path)

            final_dir = str(new_path)
        else:
            final_dir = base_dir

        # Create the directory
        Path(final_dir).mkdir(parents=True, exist_ok=True)

    def _determine_kernel_bound_status(
        self,
        ai_value: float,
        performance: float,
        cache_level: str,
        ceiling_data: dict[str, Any],
    ) -> str:
        """
        Calculate if a kernel point is memory-bound or compute-bound
        based on its own cache level's roofline
        """
        cache_key = cache_level.replace("ai_", "")

        # Get bw for this cache level
        if cache_key not in ceiling_data or not ceiling_data[cache_key]:
            return "Unknown"

        cache_data = ceiling_data[cache_key]
        if not isinstance(cache_data, (list, tuple)) or len(cache_data) < 3:
            return "Unknown"

        bandwidth = cache_data[2]

        # Get min peak performance
        min_peak = float("inf")
        if "valu" in ceiling_data and ceiling_data["valu"]:
            min_peak = min(min_peak, ceiling_data["valu"][2])
        if "matrix_ops" in ceiling_data and ceiling_data["matrix_ops"]:
            min_peak = min(min_peak, ceiling_data["matrix_ops"][2])

        if min_peak == float("inf"):
            return "Unknown"

        x_intersect = min_peak / bandwidth

        if ai_value < x_intersect:
            return "Memory Bound"
        else:
            return "Compute Bound"

    @demarcate
    def construct_plotly_figures(
        self, ai_data: dict[str, Any]
    ) -> tuple[Optional[go.Figure], Optional[go.Figure], str, str]:
        """
        Build raw Plotly figure objects from pre-computed AI data.

        Returns (ops_figure, flops_figure, ops_dt_list, flops_dt_list).
        No I/O or HTML wrapping.
        """
        self.roof_setup()

        console_debug("roofline", f"Path: {self.__run_parameters.get('workload_dir')}")

        self.__ai_data = ai_data

        msg = "AI at each mem level:"
        for key, value in self.__ai_data.items():
            msg += f"\n\t{key} -> {value}"
        console_debug(msg)

        kernel_names_data = None
        if self.__ai_data and "kernelNames" in self.__ai_data:
            original_kernel_names = self.__ai_data.get("kernelNames", [])
            filtered_kernel_names = [
                name
                for name in original_kernel_names
                if name != "nan" and isinstance(name, str)
            ]
            if len(filtered_kernel_names) > 0:
                kernel_names_data = {
                    "kernel_names": filtered_kernel_names,
                    "num_kernels": len(filtered_kernel_names),
                }

        ops_figure = flops_figure = None
        ops_dt_list = flops_dt_list = ""

        for dt in self.__run_parameters.get("roofline_data_type", []):
            gpu_arch = getattr(self.__mspec, "gpu_arch", "unknown_arch")
            if (
                "SUPPORTED_DATATYPES" not in globals()
                or gpu_arch not in SUPPORTED_DATATYPES
                or str(dt) not in SUPPORTED_DATATYPES[gpu_arch]
            ):
                console_error(
                    f"{dt} is not a supported datatype for roofline profiling on "
                    f"{getattr(self.__mspec, 'gpu_model', 'N/A')} (arch: {gpu_arch})",
                    exit=False,
                )
                continue

            ops_flops = "Ops" if str(dt).startswith("I") else "Flops"

            if ops_flops == "Ops":
                if ops_figure:
                    ops_figure = self.generate_plot(
                        dtype=str(dt),
                        fig=ops_figure,
                    )
                else:
                    ops_figure = self.generate_plot(
                        dtype=str(dt),
                        kernel_names_data=kernel_names_data,
                    )
                ops_dt_list += "_" + str(dt)

            if ops_flops == "Flops":
                if flops_figure:
                    flops_figure = self.generate_plot(
                        dtype=str(dt),
                        fig=flops_figure,
                    )
                else:
                    flops_figure = self.generate_plot(
                        dtype=str(dt),
                        kernel_names_data=kernel_names_data,
                    )
                flops_dt_list += "_" + str(dt)

        return ops_figure, flops_figure, ops_dt_list, flops_dt_list

    def save_html_files(
        self,
        ops_figure: Optional[go.Figure],
        flops_figure: Optional[go.Figure],
        ops_dt_list: str,
        flops_dt_list: str,
    ) -> None:
        """Write Plotly figures to standalone HTML files on disk."""
        dev_id = str(self.__run_parameters["device_id"])
        kernel_list = ""
        if self.__run_parameters.get("kernel_filter", False):
            kernels = getattr(self.__args, "gpu_kernel", None)
            if kernels:
                flat = [
                    str(k)
                    for group in kernels
                    for k in (group if isinstance(group, list) else [group])
                ]
                for name in sorted(flat):
                    kernel_list += "_" + name

        workload_dir = self.__run_parameters["workload_dir"]

        wrote = False
        if ops_figure:
            ops_figure.write_html(
                f"{workload_dir}/empirRoof_gpu-{dev_id}{ops_dt_list}{kernel_list}.html"
            )
            wrote = True

        if flops_figure:
            flops_figure.write_html(
                f"{workload_dir}/empirRoof_gpu-{dev_id}{flops_dt_list}{kernel_list}.html"
            )
            wrote = True

        if wrote:
            console_log("roofline", "Roofline HTML files saved.")

    @staticmethod
    def generate_html_section(
        ops_figure: Optional[go.Figure],
        flops_figure: Optional[go.Figure],
    ) -> Optional[html.Section]:
        """Wrap Plotly figures in Dash HTML components for WebUI embedding."""
        if ops_figure is None and flops_figure is None:
            return None

        ops_graph = (
            html.Div(
                className="float-child",
                children=[
                    html.H3(children="Empirical Roofline Analysis (Ops)"),
                    dcc.Graph(figure=ops_figure),
                ],
            )
            if ops_figure
            else None
        )

        flops_graph = (
            html.Div(
                className="float-child",
                children=[
                    html.H3(children="Empirical Roofline Analysis (Flops)"),
                    dcc.Graph(figure=flops_figure),
                ],
            )
            if flops_figure
            else None
        )

        return html.Section(
            id="roofline",
            children=[
                html.Div(
                    className="float-container",
                    children=[
                        ops_graph,
                        flops_graph,
                    ],
                )
            ],
        )

    @demarcate
    def generate_plot(
        self,
        dtype: str,
        fig: Optional[go.Figure] = None,
        kernel_names_data: Optional[dict] = None,
    ) -> go.Figure:
        """
        Create graph object from ai_data (coordinate points) and ceiling_data
        (peak FLOP and BW) data.
        """
        is_new_figure = fig is None
        has_kernel_names = kernel_names_data is not None and is_new_figure
        skipAI = not is_new_figure

        subplot_row = None
        total_figure_height = 600  # default height

        if is_new_figure:
            if has_kernel_names:
                raw_kernel_names = kernel_names_data.get("kernel_names", [])
                num_kernels = len(raw_kernel_names)

                wrapped_kernel_names = [wrap_text(name) for name in raw_kernel_names]
                lines_per_kernel = [
                    text.count("<br>") + 1 for text in wrapped_kernel_names
                ]
                temp_ceiling_data = construct_roof(
                    roofline_parameters=self.__run_parameters,
                    dtype=dtype,
                    ai_data=self.__ai_data,
                )

                plot_points_data = []
                cache_colors = {
                    "ai_l1": "blue",
                    "ai_l2": "green",
                    "ai_hbm": "red",
                    "ai_lds": "orange",
                }

                for cache_level in ["ai_l1", "ai_l2", "ai_hbm"]:
                    if cache_level in self.__ai_data:
                        x_vals = self.__ai_data[cache_level][0]
                        y_vals = self.__ai_data[cache_level][1]

                        for i in range(min(len(x_vals), num_kernels)):
                            if x_vals[i] > 0 and y_vals[i] > 0:
                                status = self._determine_kernel_bound_status(
                                    ai_value=x_vals[i],
                                    performance=y_vals[i],
                                    cache_level=cache_level,
                                    ceiling_data=temp_ceiling_data,
                                )

                                plot_points_data.append({
                                    "symbol": None,
                                    "color": cache_colors.get(cache_level, "gray"),
                                    "cache_level": cache_level.replace(
                                        "ai_", "", 1
                                    ).upper(),
                                    "ai": f"{x_vals[i]:.2f}",
                                    "performance": f"{y_vals[i]:.2f}",
                                    "status": status,
                                    "kernel_idx": i,
                                })

                ######################################
                # Define Figure Measurement Constants
                ######################################

                ROOFLINE_PLOT_HEIGHT = 500  # Default height of plot itself

                POINTS_ROW_HEIGHT = 25  # Pixel height of each plot point row
                num_plot_points = len(plot_points_data)  # Number of plot points
                PLOT_POINTS_HEIGHT = (
                    num_plot_points + 2
                ) * POINTS_ROW_HEIGHT  # +2 for header and spacing

                BASE_ROW_HEIGHT = 15  # Base pixel height of each kernel name row
                KERNEL_PADDING = 8  # Padding in between each kernel name row
                KERNEL_NAMES_HEIGHT = (
                    sum(lines_per_kernel) * BASE_ROW_HEIGHT
                    + (num_kernels - 1) * KERNEL_PADDING
                    + BASE_ROW_HEIGHT
                )

                total_figure_height = (
                    ROOFLINE_PLOT_HEIGHT + PLOT_POINTS_HEIGHT + KERNEL_NAMES_HEIGHT
                )

                total_content_height = (
                    ROOFLINE_PLOT_HEIGHT + PLOT_POINTS_HEIGHT + KERNEL_NAMES_HEIGHT
                )
                roofline_ratio = ROOFLINE_PLOT_HEIGHT / total_content_height
                plot_points_ratio = PLOT_POINTS_HEIGHT / total_content_height
                kernel_names_ratio = 1 - roofline_ratio - plot_points_ratio
                SUBPLOT_SPACING_PX = 80  # Constant - num of pixels between each subplot
                fig = make_subplots(
                    rows=3,
                    cols=1,
                    row_heights=[roofline_ratio, plot_points_ratio, kernel_names_ratio],
                    subplot_titles=[
                        f"Roofline Analysis ({dtype})",
                        "Plot Points & Values",
                        "Full Kernel Names",
                    ],
                    vertical_spacing=SUBPLOT_SPACING_PX / total_figure_height,
                    specs=[
                        [{"type": "scatter"}],  # Roofline plot
                        [{"type": "scatter"}],  # Plot points table
                        [{"type": "scatter"}],  # Kernel names table
                    ],
                )

                subplot_row = 1
                skipAI = False
            else:
                # generate an empty figure object in the
                # event that no kernel names are provided
                fig = go.Figure()
        else:
            # Adding to existing figure
            if hasattr(fig, "_grid_ref") and fig._grid_ref is not None:
                subplot_row = 1
                if hasattr(fig, "layout") and hasattr(fig.layout, "height"):
                    total_figure_height = fig.layout.height
            skipAI = True

        self.__ceiling_data = construct_roof(
            roofline_parameters=self.__run_parameters,
            dtype=dtype,
            ai_data=self.__ai_data,
        )
        console_debug("roofline", f"Ceiling data:\n{self.__ceiling_data}")

        if all(
            v is None or all(x is None for x in v) for v in self.__ceiling_data.values()
        ):
            console_warning(
                "Unable to generate roofline plot due to missing or corrupted "
                "benchmark data. Returning empty figure."
            )
            return fig if fig is not None else go.Figure()

        ops_flops = "OP" if dtype.startswith("I") else "FLOP"
        subplot_kwargs = {"row": subplot_row, "col": 1} if subplot_row else {}

        #######################
        # Plot Application AI
        #######################
        if ops_flops == "FLOP" and not skipAI:
            kernel_names = self.__ai_data.get("kernelNames", [])
            symbols_list = [SYMBOLS[i % len(SYMBOLS)] for i in range(len(kernel_names))]
            show_in_legend = not self.__run_parameters["is_standalone"]
            if self.__ai_data["ai_l1"][0]:
                fig.add_trace(
                    go.Scatter(
                        x=self.__ai_data["ai_l1"][0],
                        y=self.__ai_data["ai_l1"][1],
                        name="L1",
                        mode="markers",
                        marker=dict(
                            color="blue",
                            size=10,
                            symbol=symbols_list[: len(self.__ai_data["ai_l1"][0])],
                        ),
                        showlegend=show_in_legend,
                    ),
                    **subplot_kwargs,
                )

            if self.__ai_data["ai_l2"][0]:
                fig.add_trace(
                    go.Scatter(
                        x=self.__ai_data["ai_l2"][0],
                        y=self.__ai_data["ai_l2"][1],
                        name="L2",
                        mode="markers",
                        marker=dict(
                            color="green",
                            size=10,
                            symbol=symbols_list[: len(self.__ai_data["ai_l2"][0])],
                        ),
                        showlegend=show_in_legend,
                    ),
                    **subplot_kwargs,
                )

            if self.__ai_data["ai_hbm"][0]:
                fig.add_trace(
                    go.Scatter(
                        x=self.__ai_data["ai_hbm"][0],
                        y=self.__ai_data["ai_hbm"][1],
                        name="HBM",
                        mode="markers",
                        marker=dict(
                            color="red",
                            size=10,
                            symbol=symbols_list[: len(self.__ai_data["ai_hbm"][0])],
                        ),
                        showlegend=show_in_legend,
                    ),
                    **subplot_kwargs,
                )

        #######################
        # Bandwidth Ceilings
        #######################
        mem_level_config = self.__run_parameters.get("mem_level", "ALL")
        cache_hierarchy = (
            ["HBM", "L2", "L1", "LDS"]
            if mem_level_config == "ALL" or mem_level_config == ["ALL"]
            else (
                mem_level_config
                if isinstance(mem_level_config, list)
                else [mem_level_config]
            )
        )

        bandwidth_lines = []
        for level in cache_hierarchy:
            key = level.lower()
            line_data = self.__ceiling_data.get(key)
            if (
                line_data
                and isinstance(line_data, (list, tuple))
                and len(line_data) >= 3
            ):
                bandwidth_lines.append({
                    "key": key,
                    "level": level,
                    "x": line_data[0],
                    "y": line_data[1],
                    "value": line_data[2],
                    "dtype": dtype,
                })

        for bw_line in bandwidth_lines:
            value = to_int(bw_line["value"])
            level = bw_line["level"]

            trace_to_update = None
            for trace in fig.data:
                is_correct_level = trace.name and trace.name.startswith(
                    f"{level.upper()}-"
                )
                has_correct_value = False
                if trace.name and "<br>" in trace.name:
                    try:
                        # Extract value from legend name
                        value_part = trace.name.split("<br>")[1]
                        existing_val = int(value_part.split()[0])
                        if existing_val == value:
                            has_correct_value = True
                    except (ValueError, IndexError):
                        pass

                if is_correct_level and has_correct_value:
                    trace_to_update = trace
                    break

            if trace_to_update:
                try:
                    # Extract existing datatypes from name
                    name_part = trace_to_update.name.split("<br>")[0]
                    existing_dts_str = name_part.split("-", 1)[1]
                    existing_dts = [dt.strip() for dt in existing_dts_str.split(",")]
                except Exception:
                    continue

                all_dts = sorted(list(set(existing_dts + [dtype])))
                all_dts_str = ", ".join(all_dts)
                legend_name = f"{level.upper()}-{all_dts_str}<br>{value} GB/s"

                fig.update_traces(
                    patch={
                        "name": legend_name,
                        "hovertemplate": f"<b>{legend_name}</b><extra></extra>",
                    },
                    selector={"name": trace_to_update.name},
                )
            else:
                # New bandwidth line with value in legend
                legend_name = f"{level.upper()}-{dtype}<br>{value} GB/s"

                fig.add_trace(
                    go.Scatter(
                        x=bw_line["x"],
                        y=bw_line["y"],
                        name=legend_name,
                        mode="lines",
                        hovertemplate=f"<b>{legend_name}</b><extra></extra>",
                    ),
                    **subplot_kwargs,
                )

        #######################
        # Peak Performance
        #######################
        valu_data = (
            self.__ceiling_data.get("valu") if dtype in PEAK_OPS_DATATYPES else None
        )
        matrix_data = (
            self.__ceiling_data.get("matrix_ops") if dtype in MATRIX_DATATYPES else None
        )

        if valu_data:
            legend_name = f"Peak VALU-{dtype}<br>{to_int(valu_data[2])} G{ops_flops}/s"
            fig.add_trace(
                go.Scatter(
                    x=valu_data[0],
                    y=valu_data[1],
                    name=legend_name,
                    mode="lines",
                    hovertemplate=f"<b>{legend_name}</b><extra></extra>",
                ),
                **subplot_kwargs,
            )

        if matrix_data:
            legend_name = (
                f"Peak MFMA-{dtype}<br>{to_int(matrix_data[2])} G{ops_flops}/s"
            )
            fig.add_trace(
                go.Scatter(
                    x=matrix_data[0],
                    y=matrix_data[1],
                    name=legend_name,
                    mode="lines",
                    hovertemplate=f"<b>{legend_name}</b><extra></extra>",
                ),
                **subplot_kwargs,
            )

        #######################
        # Plot Points Table
        #######################
        if is_new_figure and has_kernel_names:
            symbols_list = [SYMBOLS[i % len(SYMBOLS)] for i in range(num_kernels)]

            for point in plot_points_data:
                point["symbol"] = symbols_list[point["kernel_idx"]]

            if not plot_points_data or len(plot_points_data) == 0:
                fig.add_annotation(
                    x=0.5,
                    y=1,
                    text="<b>No plot points available</b>",
                    showarrow=False,
                    xanchor="center",
                    yanchor="middle",
                    font=dict(size=12, color="black"),
                    row=2,
                    col=1,
                )

                fig.update_xaxes(visible=False, range=[0, 1], row=2, col=1)
                fig.update_yaxes(visible=False, range=[0, 2], row=2, col=1)

            else:
                header_y = len(plot_points_data) + 1
                header_positions = {
                    "Symbol": 0.020,
                    f"{ops_flops}s/Byte": 0.15,
                    f"G{ops_flops}/s": 0.35,
                    "Status": 0.55,
                    "Cache Level": 0.80,
                }

                for header_text, x_pos in header_positions.items():
                    fig.add_annotation(
                        x=x_pos,
                        y=header_y,
                        text=f"<b>{header_text}</b>",
                        showarrow=False,
                        xanchor="left",
                        yanchor="middle",
                        font=dict(size=11, color="black"),
                        row=2,
                        col=1,
                    )

                # Scatter plot for symbols
                symbol_x = []
                symbol_y = []
                symbol_markers = []
                symbol_colors = []

                for idx, point in enumerate(plot_points_data):
                    symbol_x.append(0.05)
                    symbol_y.append(len(plot_points_data) - idx)
                    symbol_markers.append(point["symbol"])
                    symbol_colors.append(point["color"])

                fig.add_trace(
                    go.Scatter(
                        x=symbol_x,
                        y=symbol_y,
                        mode="markers",
                        marker=dict(
                            symbol=symbol_markers,
                            size=11,
                            color=symbol_colors,
                            line=dict(width=0, color="black"),
                        ),
                        customdata=[
                            [point["kernel_idx"], point["cache_level"]]
                            for point in plot_points_data
                        ],
                        showlegend=False,
                        hoverinfo="skip",
                    ),
                    row=2,
                    col=1,
                )
                # ai, perf, status, cache_level
                data_positions = [0.15, 0.35, 0.55, 0.80]

                for idx, point in enumerate(plot_points_data):
                    y_pos = len(plot_points_data) - idx

                    # Background shading for every other row
                    if idx % 2 == 0:
                        fig.add_shape(
                            type="rect",
                            x0=0,
                            x1=1,
                            y0=y_pos - 1 / 2,
                            y1=y_pos + 1 / 2,
                            fillcolor="rgba(220, 220, 220, 0.3)",
                            line_width=0,
                            layer="below",
                            row=2,
                            col=1,
                        )

                    # Border lines for this row
                    fig.add_shape(
                        type="line",
                        x0=0,
                        x1=1,
                        y0=y_pos - 0.5,
                        y1=y_pos - 0.5,
                        line=dict(color="rgba(150, 150, 150, 0.5)", width=1),
                        row=2,
                        col=1,
                    )

                    fig.add_annotation(
                        x=data_positions[0],
                        y=y_pos,
                        text=point["ai"],
                        showarrow=False,
                        xanchor="left",
                        yanchor="middle",
                        font=dict(size=10, color="black"),
                        row=2,
                        col=1,
                    )
                    fig.add_annotation(
                        x=data_positions[1],
                        y=y_pos,
                        text=point["performance"],
                        showarrow=False,
                        xanchor="left",
                        yanchor="middle",
                        font=dict(size=10, color="black"),
                        row=2,
                        col=1,
                    )

                    status_text = point["status"]

                    if "Compute Bound" in status_text:
                        status_color = "DarkOrange"
                    elif "Memory Bound" in status_text:
                        status_color = "blue"
                    else:
                        status_color = "gray"
                    fig.add_annotation(
                        x=data_positions[2],
                        y=y_pos,
                        text=status_text,
                        showarrow=False,
                        xanchor="left",
                        yanchor="middle",
                        font=dict(size=10, color=status_color),
                        row=2,
                        col=1,
                    )

                    fig.add_annotation(
                        x=data_positions[3],
                        y=y_pos,
                        text=point["cache_level"],
                        showarrow=False,
                        xanchor="left",
                        yanchor="middle",
                        font=dict(size=10, color="black"),
                        row=2,
                        col=1,
                    )

                # Vertical column separators
                column_x_positions = [0.12, 0.32, 0.52, 0.75]
                for x_pos in column_x_positions:
                    fig.add_shape(
                        type="line",
                        x0=x_pos,
                        x1=x_pos,
                        y0=0.5,
                        y1=header_y + 0.5,
                        line=dict(color="rgba(150, 150, 150, 0.5)", width=1),
                        row=2,
                        col=1,
                    )

                # Configure Plot Points subplot axes
                fig.update_xaxes(
                    visible=False, range=[0, 1], fixedrange=True, row=2, col=1
                )
                fig.update_yaxes(
                    visible=False,
                    range=[0, (len(plot_points_data) + 1.5)],
                    fixedrange=True,
                    row=2,
                    col=1,
                )

            #######################
            # Kernel Names Table
            #######################

            y_positions = []
            row_heights = []
            current_y = 0
            KERNEL_PADDING = 0
            for i in range(num_kernels):
                # Height for this kernel is proportional to its number of lines
                kernel_height = lines_per_kernel[i]
                row_heights.append(kernel_height)
                # Position at the center of this kernel's allocated space
                current_y += kernel_height / 2
                y_positions.append(current_y)
                current_y += kernel_height / 2 + KERNEL_PADDING

            # Reverse to display top to bottom
            y_positions = [current_y - y - KERNEL_PADDING / 2 for y in y_positions]
            max_y = current_y
            min_y = 0

            kernel_symbol_x = []
            kernel_symbol_y = []
            kernel_symbol_markers = []

            for i in range(num_kernels):
                kernel_symbol_x.append(0.05)
                kernel_symbol_y.append(y_positions[i])
                kernel_symbol_markers.append(symbols_list[i])

                # Background shading for every other row
                if i % 2 == 0:
                    fig.add_shape(
                        type="rect",
                        x0=0,
                        x1=1,
                        y0=y_positions[i] - row_heights[i] / 2,
                        y1=y_positions[i] + row_heights[i] / 2,
                        fillcolor="rgba(220, 220, 220, 0.3)",
                        line_width=0,
                        layer="below",
                        row=3,
                        col=1,
                    )

                # Border lines for this kernel
                fig.add_shape(
                    type="line",
                    x0=0,
                    x1=1,
                    y0=y_positions[i] - row_heights[i] / 2,
                    y1=y_positions[i] - row_heights[i] / 2,
                    line=dict(color="rgba(150, 150, 150, 0.5)", width=1),
                    row=3,
                    col=1,
                )

                # Kernel name annotation with wrapped text (left aligned)
                fig.add_annotation(
                    x=0.15,
                    y=y_positions[i],
                    text=wrapped_kernel_names[i],
                    showarrow=False,
                    xanchor="left",
                    yanchor="middle",
                    align="left",
                    font=dict(size=10, color="black"),
                    row=3,
                    col=1,
                )

            # Vertical separator between symbol and kernel name
            fig.add_shape(
                type="line",
                x0=0.12,
                x1=0.12,
                y0=min_y,
                y1=max_y,
                line=dict(color="rgba(150, 150, 150, 0.5)", width=1),
                row=3,
                col=1,
            )

            fig.add_trace(
                go.Scatter(
                    x=kernel_symbol_x,
                    y=kernel_symbol_y,
                    mode="markers",
                    marker=dict(
                        symbol=kernel_symbol_markers,
                        size=11,
                        color="black",
                        line=dict(width=0, color="black"),
                    ),
                    showlegend=False,
                    hoverinfo="skip",
                ),
                row=3,
                col=1,
            )

            # Configure Kernel Names subplot axes
            fig.update_xaxes(visible=False, range=[0, 1], fixedrange=True, row=3, col=1)
            fig.update_yaxes(
                visible=False, range=[min_y, max_y], fixedrange=True, row=3, col=1
            )

        #######################
        # Layout Configuration
        #######################
        if is_new_figure:
            if subplot_row:
                fig.update_xaxes(
                    type="log",
                    autorange=True,
                    title_text=f"Arithmetic Intensity ({ops_flops}s/Byte)",
                    row=1,
                    col=1,
                )
                fig.update_yaxes(
                    type="log",
                    autorange=True,
                    title_text=f"Performance (G{ops_flops}/sec)",
                    row=1,
                    col=1,
                )
                fig.update_layout(
                    height=int(total_figure_height),
                    width=1000,
                    hovermode="x unified",
                    margin=dict(l=50, r=180, b=50, t=80, pad=7),
                    legend=dict(
                        orientation="v",
                        yanchor="top",
                        y=1,
                        xanchor="left",
                        x=1.01,
                        font=dict(size=10),
                    ),
                )
            else:
                # Fallback to simple figure without subplots
                fig.update_layout(
                    xaxis_title=f"Arithmetic Intensity ({ops_flops}s/Byte)",
                    yaxis_title=f"Performance (G{ops_flops}/sec)",
                    xaxis_type="log",
                    yaxis_type="log",
                    xaxis_autorange=True,
                    yaxis_autorange=True,
                    height=int(total_figure_height),
                    hovermode="x unified",
                    margin=dict(l=50, r=50, b=50, t=50, pad=7),
                )

        # Update subplot title for additional datatypes
        if (
            not is_new_figure
            and subplot_row
            and hasattr(fig, "layout")
            and hasattr(fig.layout, "annotations")
        ):
            for annotation in fig.layout.annotations:
                if annotation.text and "Roofline Analysis" in annotation.text:
                    if "(" in annotation.text and ")" in annotation.text:
                        existing_text = annotation.text.split("(")[0]
                        existing_types = annotation.text.split("(")[1].split(")")[0]
                        new_types = f"{existing_types}, {dtype}"
                        annotation.text = f"{existing_text}({new_types})"
                    break

        return fig

    def cli_generate_plot(
        self,
        dtype: str,
        ai_data: dict[str, Any],
    ) -> Optional[str]:
        """
        Plot CLI mode roofline analysis in terminal using plotext

        :param dtype: The datatype to be profiled
        :param ai_data: Pre-computed arithmetic intensity data from calc_ai_analyze
        :return: Build the current figure using plot.build(),
        or None if datatype is not valid for the architecture
        :rtype: str or None
        """
        console_debug("roofline", "Generating roofline plot for CLI")

        if not (str(dtype) in SUPPORTED_DATATYPES[str(self.__mspec.gpu_arch)]):
            console_error(
                f"{dtype} is not a supported datatype for roofline profiling on "
                f"{getattr(self.__mspec, 'gpu_model', 'N/A')} (arch: "
                f"{self.__mspec.gpu_arch})",
                exit=False,
            )
            return

        if not ai_data:
            console_warning(
                "roofline",
                "Skipping roofline charting due to invalid arithmetic intensity data",
            )
            return

        self.__ai_data = ai_data

        workload_dir = self.__run_parameters.get("workload_dir", "")
        if not (Path(workload_dir) / "roofline.csv").is_file():
            console_log(
                "roofline",
                f"{workload_dir}/roofline.csv does not exist",
            )
            return None

        self.__ceiling_data = construct_roof(
            roofline_parameters=self.__run_parameters, dtype=dtype
        )

        self.roof_setup()

        # Check proper datatype input - takes single str
        if not isinstance(dtype, str):
            console_error("Unsupported datatype input - must be str")

        # Defensive copy; vL1D→L1 normalization happens at the analysis entry point.
        raw_mem = self.__run_parameters["mem_level"]
        mem_level = list(raw_mem) if isinstance(raw_mem, list) else raw_mem

        color_scheme = {
            "HBM": "blue+",
            "L2": "green+",
            "L1": "red+",
            "LDS": "orange+",
            "VALU": "white",
            "Matrix": "magenta+",
        }

        kernel_markers = {
            0: "star",
            1: "cross",
            2: "sd",
            3: "shamrock",
            4: "at",
            5: "atom",
        }

        plt.clf()
        plt.plotsize(plt.tw(), plt.th())

        ops_flops = "OP" if dtype.startswith("I") else "FLOP"

        # Plot bandwidth lines
        cache_hierarchy = (
            ["HBM", "L2", "L1", "LDS"]
            if mem_level == "ALL" or mem_level == ["ALL"]
            else mem_level
        )

        for cache_level in cache_hierarchy:
            cache_key = cache_level.lower()
            if self.__ceiling_data[cache_key][0] is None:
                continue
            plt.plot(
                self.__ceiling_data[cache_key][0],
                self.__ceiling_data[cache_key][1],
                label=f"{cache_level}-{dtype}",
                marker="braille",
                color=color_scheme[cache_level],
            )
            plt.text(
                f"{round(self.__ceiling_data[cache_key][2])} GB/s",
                x=self.__ceiling_data[cache_key][0][0],
                y=self.__ceiling_data[cache_key][1][0],
                background="black",
                color="white",
                alignment="left",
            )
            console_debug(
                "roofline",
                f"{cache_level}: [{self.__ceiling_data[cache_key][0][0]},"
                f"{self.__ceiling_data[cache_key][0][1]}], "
                f"[{self.__ceiling_data[cache_key][1][0]},"
                f"{self.__ceiling_data[cache_key][1][1]}], "
                f"{self.__ceiling_data[cache_key][2]}",
            )

        # Plot VALU and Matrix Ops Peak
        if dtype in PEAK_OPS_DATATYPES and self.__ceiling_data["valu"][0] is not None:
            plt.plot(
                self.__ceiling_data["valu"][0],
                [
                    self.__ceiling_data["valu"][1][0] - 0.1,
                    self.__ceiling_data["valu"][1][1] - 0.1,
                ],
                label=f"Peak VALU-{dtype}",
                marker="braille",
                color=color_scheme["VALU"],
            )
            plt.text(
                f"{round(self.__ceiling_data['valu'][2])} G{ops_flops}/s",
                x=self.__ceiling_data["valu"][0][1] - 800,
                y=self.__ceiling_data["valu"][1][1],
                background="black",
                color="white",
                alignment="right",
            )
            console_debug(
                "roofline",
                f"VALU: [{self.__ceiling_data['valu'][0][0]},"
                f"{self.__ceiling_data['valu'][0][1]}], "
                f"[{self.__ceiling_data['valu'][1][0]},"
                f"{self.__ceiling_data['valu'][1][1]}], "
                f"{self.__ceiling_data['valu'][2]}",
            )
        else:
            console_warning(f"No PEAK measurement available for {dtype}")

        if (
            dtype in MATRIX_DATATYPES
            and self.__ceiling_data["matrix_ops"][0] is not None
        ):
            plt.plot(
                self.__ceiling_data["matrix_ops"][0],
                [
                    self.__ceiling_data["matrix_ops"][1][0] - 0.1,
                    self.__ceiling_data["matrix_ops"][1][1] - 0.1,
                ],
                label=f"Peak MFMA-{dtype}",
                marker="braille",
                color=color_scheme["Matrix"],
            )
            plt.text(
                f"{round(self.__ceiling_data['matrix_ops'][2])} G{ops_flops}/s",
                x=self.__ceiling_data["matrix_ops"][0][1] - 800,
                y=self.__ceiling_data["matrix_ops"][1][1],
                background="black",
                color="white",
                alignment="right",
            )
            console_debug(
                "roofline",
                f"Matrix Ops: [{self.__ceiling_data['matrix_ops'][0][0]},"
                f"{self.__ceiling_data['matrix_ops'][0][1]}], "
                f"[{self.__ceiling_data['matrix_ops'][1][0]},"
                f"{self.__ceiling_data['matrix_ops'][1][1]}], "
                f"{self.__ceiling_data['matrix_ops'][2]}",
            )
        else:
            console_warning(f"No Matrix Ops measurement available for {dtype}")

        # Plot Application AI
        for cache_level in cache_hierarchy:
            key = f"ai_{cache_level.lower()}"
            if key not in self.__ai_data:
                continue

            kernel_names = self.__ai_data.get("kernelNames", [])
            for i in range(len(self.__ai_data.get("kernelNames", []))):
                # Zero intensity level means no data reported for this cache level
                if self.__ai_data[key][0][i] > 0 and self.__ai_data[key][1][i] > 0:
                    plt.plot(
                        [self.__ai_data[key][0][i]],
                        [self.__ai_data[key][1][i]],
                        label=f"AI_{cache_level}_{kernel_names[i]}",
                        color=color_scheme[cache_level],
                        marker=kernel_markers[i % len(kernel_markers)],
                    )
                val1 = (
                    self.__ai_data[key][0][i]
                    if i < len(self.__ai_data[key][0])
                    else "N/A"
                )
                val2 = (
                    self.__ai_data[key][1][i]
                    if i < len(self.__ai_data[key][1])
                    else "N/A"
                )
                console_debug("roofline", f"AI_{kernel_names[i]}: {val1}, {val2}")
        plt.xlabel(f"Arithmetic Intensity ({ops_flops}s/Byte)")
        plt.ylabel("Performance (GFLOP/sec)")
        wdir = self.__run_parameters.get("workload_dir", "")
        plt.title(f"Roofline ({dtype}) - {wdir}")

        # Canvas config
        plt.theme("pro")
        plt.xscale("log")
        plt.yscale("log")

        # Build figure
        # Print plot using `plt._utility.write(self.cli_generate_plot(dtype))`
        return plt.build()

    def get_dtype(self) -> list[str]:
        return self.__run_parameters["roofline_data_type"]
