from __future__ import annotations

import os
import tempfile
from typing import List, Tuple, Optional, Dict, Any, Iterable, Sequence, Union
import textwrap

import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from music21 import note, chord, pitch as pitch_module
try:
    from tqdm.auto import tqdm as _tqdm  # Notebook/terminal-friendly progress bar
except Exception:  # pragma: no cover - optional dependency at runtime
    _tqdm = None


__all__ = [
    "get_file_path",
    "extract_voice_data",
    "filter_and_adjust_durations",
    "get_measure_offsets",
    "draw_piano_roll",
    "canonicalize_pitch_name",
    "accidental_rank_from_name",
    "create_piano_roll",
    "parse_files",
    "create_binary_matrix",
    "plot_binary_matrix",
    "orient_binary_matrix",
    "binary_matrix_to_df",
    "binary_matrix_to_df_from_meta",
    "binary_matrix_to_df_from_bounds",
    "round_trip_sanity_check",
    "parse_prototype_notation",
    "parse_notation",
    "create_prototype_binary_matrix",
    "plot_prototype_binary_matrix",
]

BOKEH_NOTEBOOK_INITIALIZED = False


def get_file_path(file_source: str, *, timeout_seconds: int = 30) -> str:
    """
    Resolve a local path from a URL or verify a local path exists.

    Parameters
    ----------
    file_source : str
        URL or local file path.
    timeout_seconds : int, optional
        Timeout for downloading remote files (seconds). Default is 30.

    Returns
    -------
    str
        Local filesystem path to the file.

    Raises
    ------
    ValueError
        If there's an issue downloading a remote file.
    FileNotFoundError
        If the local file does not exist.
    """
    if file_source.startswith(("http://", "https://")):
        try:
            response = requests.get(file_source, stream=True, timeout=timeout_seconds)
            response.raise_for_status()
            _, file_extension = os.path.splitext(file_source)
            temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension)
            with temp_file as tf:
                tf.write(response.content)
            return temp_file.name
        except requests.RequestException as exc:
            raise ValueError(f"Error downloading the file: {exc}") from exc
    else:
        if os.path.exists(file_source):
            return file_source
        raise FileNotFoundError(f"Local file does not exist: {file_source}")


def extract_voice_data(score) -> List[Tuple[int, float, float, float, str, str]]:
    """
    Extract measure number, onset, duration, pitch, and voice/channel labels from a music21 score.

    Parameters
    ----------
    score : music21.stream.Score
        The music21 score object.

    Returns
    -------
    list[tuple]
        Each tuple contains (Measure, Local Onset, Global Onset, Duration, Pitch, Voice).
    """

    def _resolve_voice_label(element: Any) -> str:
        labels: List[str] = []

        part = element.getContextByClass("Part")
        if part is not None:
            part_name = getattr(part, "partName", None) or getattr(part, "partAbbreviation", None) or getattr(part, "id", None)
            if part_name:
                part_label = str(part_name).strip()
                if part_label and part_label not in labels:
                    labels.append(part_label)

        try:
            instrument_obj = element.getInstrument(returnDefault=False)
        except Exception:
            instrument_obj = None

        if instrument_obj is not None:
            inst_name = getattr(instrument_obj, "instrumentName", None) or getattr(instrument_obj, "partName", None) or getattr(instrument_obj, "instrumentAbbreviation", None)
            if inst_name:
                inst_label = str(inst_name).strip()
                if inst_label and inst_label not in labels:
                    labels.append(inst_label)

        voice_ctx = element.getContextByClass("Voice")
        voice_label = None
        if voice_ctx is not None:
            raw_voice = getattr(voice_ctx, "id", None) or getattr(voice_ctx, "name", None)
            if raw_voice:
                voice_label = str(raw_voice).strip()
                if voice_label.isdigit():
                    voice_label = f"Voice {voice_label}"
            else:
                voice_index = getattr(voice_ctx, "index", None)
                if voice_index is not None:
                    voice_label = f"Voice {voice_index}"
                else:
                    voice_label = "Voice"
            if voice_label and voice_label not in labels:
                labels.append(voice_label)

        if not labels:
            return "Unknown"

        return " / ".join(labels)

    voice_data: List[Tuple[int, float, float, float, str, str]] = []
    notes_and_chords = score.flatten().notesAndRests.stream()

    for element in notes_and_chords:
        if isinstance(element, (note.Note, chord.Chord)):
            # Get the measure context
            measure = element.getContextByClass("Measure")
            if measure is not None:
                measure_num = measure.number
                measure_offset = measure.offset
            else:
                # If measure context is not found, default to 0
                measure_num = 0
                measure_offset = 0.0

            global_onset = element.offset
            local_onset = global_onset - measure_offset
            duration = element.duration.quarterLength

            # Handle notes and chords
            if isinstance(element, note.Note):
                pitches = [str(element.pitch)]
            else:
                pitches = [str(p) for p in element.pitches]

            voice_label = _resolve_voice_label(element)

            # Append data for each pitch
            for p in pitches:
                voice_data.append((measure_num, local_onset, global_onset, duration, p, voice_label))

    return voice_data


def draw_piano_roll(
    df: pd.DataFrame,
    measure_offsets: Optional[List[float]] = None,
    *,
    backend: str = "plt",
    show_measure_lines: bool = True,
    measure_line_color: str = "red",
    show_hover: bool = True,
    hover_fields: Optional[Sequence[str]] = None,
    pitch_labels: bool = True,
    show: bool = True,
    plot_width: Optional[int] = None,
    plot_height: Optional[int] = None,
    dpi: float = 100.0,
    zoom_drag_dim: Optional[str] = None,
    zoom_wheel_dim: Optional[str] = None,
    save_html: Optional[bool] = None,
    save_html_path: Optional[str] = None,
    save_png_path: Optional[str] = None,
    open_html_after_save: bool = False,
    colorize_voices: bool = False,
    palette: Optional[Union[str, Sequence[str]]] = None,
) -> Any:
    """
    Draw a piano roll visualization using the selected backend.

    Parameters
    ----------
    df : pandas.DataFrame
        Must contain 'Global Onset', 'Duration', 'MIDI', and 'Pitch'.
    measure_offsets : list[float], optional
        Global onset times where measures start. Vertical lines drawn at these positions.
    backend : {"plt", "bokeh"}
        Plotting backend to use.
    show_measure_lines : bool
        Whether to draw vertical red measure separation lines when measure offsets are provided.
    measure_line_color : str
        Color for the vertical measure lines when show_measure_lines is True.
        Accepts any Matplotlib/Bokeh color string (e.g., 'crimson', '#ff0000').
    show_hover : bool
        When backend == 'bokeh', add a HoverTool with configurable fields. Default True.
    hover_fields : Sequence[str], optional
        List of fields to show in the hover tooltip (bokeh only). Supported keys:
        ['pitch', 'midi', 'voice', 'measure', 'global_onset', 'local_onset', 'duration', 'xml_id'].
        Defaults to a sensible ordering when None.
    pitch_labels : bool
        Whether to use pitch names on the y-axis when supported.
    show : bool
        Whether to immediately show the plot (where applicable).
    plot_width : int, optional
        Width in pixels for both backends. If None, defaults to 900.
    plot_height : int, optional
        Height in pixels for both backends. If None, defaults to 600.
    dpi : float, optional
        DPI for matplotlib backend (used to convert pixels to inches). Default is 100.
    zoom_drag_dim : {"width", "height", "both"}, optional
        Dimension for box zoom drag tool (Bokeh only). None defaults to "both".
    zoom_wheel_dim : {"width", "height", "both"}, optional
        Dimension for wheel zoom tool (Bokeh only). None defaults to "both".
    save_html : bool, optional
        If None (default), behaves like legacy mode: saves HTML when save_html_path is provided.
        If True, saves HTML to save_html_path if provided, otherwise to "piano_roll.html".
        If False, disables HTML saving regardless of save_html_path.
    save_html_path : str, optional
        When backend == 'bokeh', if provided, saves the plot as a standalone HTML file.
    save_png_path : str, optional
        When backend == 'bokeh', if provided, attempts to export a PNG. Requires selenium
        and a compatible webdriver installed (e.g., chromedriver or geckodriver).
    open_html_after_save : bool, optional
        If True and save_html_path is provided, attempts to open the saved HTML in a browser.
    colorize_voices : bool, optional
        When True and a 'Voice' column is present in df, color notes by voice/part.
        Defaults to False (uniform color).
    palette : str | Sequence[str], optional
        Color palette to use when colorizing voices. Accepts:
          - A sequence of color strings (hex or named), or
          - A palette name:
              * For Matplotlib (plt): any valid colormap name (e.g., 'tab20', 'tab10', 'Set3')
              * For Bokeh: any key in bokeh.palettes.all_palettes (e.g., 'Category10', 'Category20')
        If not provided, a sensible categorical default is used.

    Returns
    -------
    Any
        A backend-specific figure object when show=False; None when show=True.
    """
    backend = (backend or "plt").lower()

    # Unique MIDI to pitch mapping for ticks/labels
    midi_to_pitch = df.drop_duplicates("MIDI").sort_values("MIDI").set_index("MIDI")["Pitch"].to_dict()
    midi_values = list(midi_to_pitch.keys())

    # Set default dimensions if not provided
    width_pixels = plot_width or 900
    height_pixels = plot_height or 600

    # ----------------------------
    # Voice-based color resolution
    # ----------------------------
    def _default_categorical_colors() -> List[str]:
        # Matplotlib tab10 colors as sane default usable in both backends
        return [
            "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
            "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
        ]

    def _resolve_palette_matplotlib(pal: Optional[Union[str, Sequence[str]]], n: int) -> List[str]:
        from matplotlib import colors as mcolors  # local import to avoid global dependency elsewhere
        # Map common cross-backend names to Matplotlib equivalents
        def _pal_alias(name: str) -> str:
            key = name.strip().lower()
            if key in {"category10"}:
                return "tab10"
            if key in {"category20"}:
                return "tab20"
            return name
        if pal is None:
            base = _default_categorical_colors()
            if n <= len(base):
                return base[:n]
            # Repeat if necessary
            reps = (n + len(base) - 1) // len(base)
            return (base * reps)[:n]
        if isinstance(pal, (list, tuple)):
            base = [str(c) for c in pal]
            if n <= len(base):
                return base[:n]
            reps = (n + len(base) - 1) // len(base)
            return (base * reps)[:n]
        # Treat as a Matplotlib colormap name
        try:
            cmap = plt.get_cmap(_pal_alias(str(pal)))
            if n <= 1:
                return [mcolors.to_hex(cmap(0.0))]
            return [mcolors.to_hex(cmap(i / max(1, n - 1))) for i in range(n)]
        except Exception:
            base = _default_categorical_colors()
            if n <= len(base):
                return base[:n]
            reps = (n + len(base) - 1) // len(base)
            return (base * reps)[:n]

    def _resolve_palette_bokeh(pal: Optional[Union[str, Sequence[str]]], n: int) -> List[str]:
        # Avoid importing bokeh unless necessary
        def _pal_alias(name: str) -> str:
            key = name.strip().lower()
            if key in {"tab10"}:
                return "Category10"
            if key in {"tab20"}:
                return "Category20"
            return name
        if pal is None:
            base = _default_categorical_colors()
            if n <= len(base):
                return base[:n]
            reps = (n + len(base) - 1) // len(base)
            return (base * reps)[:n]
        if isinstance(pal, (list, tuple)):
            base = [str(c) for c in pal]
            if n <= len(base):
                return base[:n]
            reps = (n + len(base) - 1) // len(base)
            return (base * reps)[:n]
        # Palette name: try bokeh.palettes
        try:
            from bokeh.palettes import all_palettes  # type: ignore
            pal_name = _pal_alias(str(pal))
            if pal_name in all_palettes:
                sizes = sorted(all_palettes[pal_name].keys())
                # Pick the largest available size not exceeding n, else the largest overall
                size_choice = max([s for s in sizes if s <= max(n, 3)], default=max(sizes))
                base = list(all_palettes[pal_name][size_choice])
                if n <= len(base):
                    return base[:n]
                reps = (n + len(base) - 1) // len(base)
                return (base * reps)[:n]
            # Some palettes (e.g., 'Viridis256') may be exposed differently
            try:
                from bokeh import palettes as _pal_mod  # type: ignore
                base = getattr(_pal_mod, pal_name, None)
                if isinstance(base, (list, tuple)):
                    base = list(base)
                    if n <= len(base):
                        return base[:n]
                    reps = (n + len(base) - 1) // len(base)
                    return (base * reps)[:n]
            except Exception:
                pass
        except Exception:
            # Fallback to default if bokeh not available or palette not found
            pass
        base = _default_categorical_colors()
        if n <= len(base):
            return base[:n]
        reps = (n + len(base) - 1) // len(base)
        return (base * reps)[:n]

    def _voice_color_mapping(for_backend: str) -> Optional[List[str]]:
        if not colorize_voices or "Voice" not in df.columns:
            return None
        voices_series = df["Voice"].astype(str)
        unique_voices = list(pd.unique(voices_series))
        num_groups = len(unique_voices)
        if num_groups <= 0:
            return None
        if for_backend == "plt":
            group_colors = _resolve_palette_matplotlib(palette, num_groups)
        else:
            group_colors = _resolve_palette_bokeh(palette, num_groups)
        color_map: Dict[str, str] = {v: group_colors[i % len(group_colors)] for i, v in enumerate(unique_voices)}
        return [color_map[v] for v in voices_series.tolist()]

    if backend == "plt":
        # Convert pixels to inches for matplotlib
        width_inches = width_pixels / dpi
        height_inches = height_pixels / dpi
        fig, ax = plt.subplots(figsize=(width_inches, height_inches))
        row_colors = _voice_color_mapping("plt")
        for idx, (_, row) in enumerate(df.iterrows()):
            color_val = (row_colors[idx] if row_colors is not None else "skyblue")
            ax.barh(
                row["MIDI"],
                width=row["Duration"],
                left=row["Global Onset"],
                height=0.6,
                color=color_val,
                edgecolor="black",
            )

        if pitch_labels:
            ax.set_yticks(midi_values)
            ax.set_yticklabels([midi_to_pitch[m] for m in midi_values])
        ax.set_xlabel("Global Onset (Quarter Lengths)")
        ax.set_ylabel("Pitch")
        ax.set_title("Piano Roll Visualization")

        if show_measure_lines and measure_offsets is not None:
            for m_offset in measure_offsets:
                ax.axvline(x=m_offset, color=str(measure_line_color), linestyle="--", linewidth=0.8)

        ax.grid(True, axis="x", linestyle="--", alpha=0.7)
        fig.tight_layout()
        if show:
            plt.show()
            # Prevent Jupyter from auto-displaying the returned figure again
            plt.close(fig)
            return None
        return fig

    if backend == "bokeh":
        try:
            from bokeh.plotting import figure, show as bokeh_show
            from bokeh.models import Span, ColumnDataSource, BoxZoomTool, WheelZoomTool, PanTool, HoverTool
            # Initialize inline output in notebooks once
            global BOKEH_NOTEBOOK_INITIALIZED
            if not BOKEH_NOTEBOOK_INITIALIZED:
                try:
                    from IPython import get_ipython  # type: ignore
                    ip = get_ipython()
                except Exception:
                    ip = None
                if ip is not None:
                    try:
                        from bokeh.io import output_notebook
                        # Force inline resources to avoid CDN issues in restricted/offline environments
                        try:
                            from bokeh.resources import INLINE
                        except Exception:
                            INLINE = "inline"
                        output_notebook(hide_banner=True, resources=INLINE)
                        BOKEH_NOTEBOOK_INITIALIZED = True
                    except Exception:
                        # If initialization fails, continue; show() may open a new tab
                        pass
        except ImportError as exc:
            raise ImportError("Bokeh is not installed. Install bokeh to use the 'bokeh' backend.") from exc

        y_min = int(min(midi_values)) - 1
        y_max = int(max(midi_values)) + 1

        row_colors = _voice_color_mapping("bokeh")
        voices_col = df["Voice"].astype(str).tolist() if ("Voice" in df.columns) else None
        xml_id_col = df["xml_id"].astype(str).tolist() if ("xml_id" in df.columns) else None
        source_data = {
            "y": df["MIDI"],
            "left": df["Global Onset"],
            "right": df["Global Onset"] + df["Duration"],
            "pitch": df["Pitch"],
            "midi": df["MIDI"],
            "global_onset": df["Global Onset"],
            "local_onset": df["Local Onset"] if "Local Onset" in df.columns else df["Global Onset"],
            "duration": df["Duration"],
        }
        if "Measure" in df.columns:
            source_data["measure"] = df["Measure"]
        if row_colors is not None:
            source_data["color"] = row_colors
        if voices_col is not None:
            source_data["voice"] = voices_col
        if xml_id_col is not None:
            source_data["xml_id"] = xml_id_col
        if "Pitch Enharmonic" in df.columns:
            source_data["pitch_enharmonic"] = df["Pitch Enharmonic"].fillna("").tolist()

        # Normalize zoom dimension options
        def _norm_dim(val: Optional[str]) -> str:
            if val is None:
                return "both"
            v = str(val).strip().lower()
            if v in {"x", "width"}:
                return "width"
            if v in {"y", "height"}:
                return "height"
            return "both"

        drag_dim = _norm_dim(zoom_drag_dim)
        wheel_dim = _norm_dim(zoom_wheel_dim)

        def _build_plot() -> Any:
            src = ColumnDataSource(data=source_data)
            plot = figure(
                height=height_pixels,
                width=width_pixels,
                title="Piano Roll Visualization",
                x_axis_label="Global Onset (Quarter Lengths)",
                y_axis_label="Pitch",
                y_range=(y_min, y_max),
                tools="pan,reset,save",
            )
            try:
                plot.output_backend = "canvas"
            except Exception:
                pass
            plot.hbar(y="y", left="left", right="right", height=0.6, source=src, fill_color="#87CEEB")
            if row_colors is not None:
                # Re-render with color field and optional legend by voice
                try:
                    # Remove the previous glyph renderer if any
                    plot.renderers = [r for r in plot.renderers if getattr(r, "glyph", None) is None]
                except Exception:
                    pass
                kwargs: Dict[str, Any] = {"fill_color": "color"}
                if voices_col is not None:
                    kwargs["legend_field"] = "voice"
                plot.hbar(y="y", left="left", right="right", height=0.6, source=src, **kwargs)

            # Optional hover
            if bool(show_hover):
                # Build tooltips from requested fields
                supported = {
                    "pitch": ("Pitch", "@pitch"),
                    "midi": ("MIDI", "@midi"),
                    "voice": ("Voice", "@voice"),
                    "measure": ("Measure", "@measure"),
                    "xml_id": ("xml-id", "@xml_id"),
                    "global_onset": ("Global Onset", "@global_onset"),
                    "local_onset": ("Local Onset", "@local_onset"),
                    "duration": ("Duration", "@duration"),
                    "pitch_enharmonic": ("Pitch (Enharmonic)", "@pitch_enharmonic"),
                }
                default_order = ["pitch", "pitch_enharmonic", "voice", "measure", "xml_id", "global_onset", "local_onset", "duration", "midi"]
                fields = [f for f in (list(hover_fields) if hover_fields is not None else default_order) if f in supported]
                tooltips = [supported[f] for f in fields]
                try:
                    hover_tool = HoverTool(tooltips=tooltips)
                    plot.add_tools(hover_tool)
                except Exception:
                    pass

            try:
                box_tool = BoxZoomTool(dimensions=drag_dim)
                wheel_tool = WheelZoomTool(dimensions=wheel_dim)
                plot.add_tools(box_tool, wheel_tool)
                pan_tool = plot.select_one(PanTool)
                if pan_tool is None:
                    pan_tool = PanTool()
                    plot.add_tools(pan_tool)
                plot.toolbar.active_drag = pan_tool
                plot.toolbar.active_scroll = wheel_tool
            except Exception:
                pass

            if show_measure_lines and measure_offsets is not None:
                for m_offset in measure_offsets:
                    plot.add_layout(Span(location=m_offset, dimension="height", line_color=str(measure_line_color), line_dash="dashed", line_width=1))

            if pitch_labels:
                plot.yaxis.ticker = midi_values
                plot.yaxis.major_label_overrides = {m: midi_to_pitch[m] for m in midi_values}

            return plot

        # Build the plot used for display/return
        p = _build_plot()

        # Optional explicit saves using separate plot instances to avoid doc ownership conflicts
        # Determine whether to save HTML (supports explicit toggle with back-compat)
        do_save_html = (bool(save_html) if save_html is not None else (save_html_path is not None))
        if do_save_html:
            try:
                from bokeh.embed import file_html  # type: ignore
                from bokeh.resources import INLINE  # type: ignore
                html = file_html(_build_plot(), INLINE, title="Piano Roll")
                import io, os
                from datetime import datetime
                html_target = str(save_html_path) if save_html_path else "piano_roll.html"
                abs_path = os.path.abspath(html_target)
                with io.open(abs_path, "w", encoding="utf-8") as f:
                    f.write(html)
                print(f"Data saved successfully to {abs_path} at {datetime.now().isoformat(timespec='seconds')}.")
                if open_html_after_save:
                    try:
                        import webbrowser
                        webbrowser.open_new_tab(abs_path)
                    except Exception:
                        pass
            except Exception as _exc:
                print(f"Warning: Failed to save HTML to '{save_html_path or 'piano_roll.html'}': {_exc}")

        if save_png_path:
            try:
                from bokeh.io import export_png  # type: ignore
                import os
                from datetime import datetime
                abs_png = os.path.abspath(str(save_png_path))
                export_png(_build_plot(), filename=abs_png)
                print(f"Data saved successfully to {abs_png} at {datetime.now().isoformat(timespec='seconds')}.")
            except Exception as _exc:
                print(
                    "Warning: Failed to export PNG. Install 'selenium' and a webdriver (e.g., chromedriver). "
                    f"Error: {_exc}"
                )

        if show:
            bokeh_show(p)
            return None
        return p

    raise ValueError("Unsupported backend. Choose from 'plt' or 'bokeh'.")



def create_piano_roll(df: pd.DataFrame, measure_offsets: Optional[List[float]] = None) -> Any:
    """
    Backward-compatible wrapper that draws using matplotlib backend.
    Shows and returns the Matplotlib figure.

    Note: In Jupyter, returning a figure as the last expression will display it.
    To avoid duplicate display, assign the return value to a variable.
    """
    fig = draw_piano_roll(df, measure_offsets=measure_offsets, backend="plt", show=False)
    # Show after obtaining the figure so we can still return it
    plt.show()
    return fig


def filter_and_adjust_durations(
    df: pd.DataFrame,
    *,
    filter_zero_duration: bool = True,
    adjust_fractional_duration: bool = True,
) -> pd.DataFrame:
    """
    Filter out zero-duration notes and adjust fractional durations.

    Parameters
    ----------
    df : pandas.DataFrame
        Original DataFrame.
    filter_zero_duration : bool, optional
        If True, removes rows with Duration <= 0. Default is True.
    adjust_fractional_duration : bool, optional
        If True, rounds 'Duration', 'Local Onset', and 'Global Onset' to 3 decimal places. Default is True.

    Returns
    -------
    pandas.DataFrame
        Processed DataFrame.
    """
    df_processed = df.copy()

    if filter_zero_duration:
        df_processed = df_processed[df_processed["Duration"] > 0]

    if adjust_fractional_duration:
        df_processed["Duration"] = df_processed["Duration"].round(3)
        df_processed["Local Onset"] = df_processed["Local Onset"].round(3)
        df_processed["Global Onset"] = df_processed["Global Onset"].round(3)

    return df_processed


def get_measure_offsets(score) -> List[float]:
    """
    Compute global onset offsets for each measure in the first part of the score.

    Parameters
    ----------
    score : music21.stream.Score
        The music21 score object.

    Returns
    -------
    list[float]
        List of measure start offsets (global onsets). Empty if unavailable.
    """
    try:
        parts = getattr(score, "parts", None)
        if not parts:
            return []
        measures = parts[0].getElementsByClass("Measure")
        return [m.offset for m in measures]
    except Exception:
        return []


# -----------------------------
# Accidentals normalization API
# -----------------------------
_ACCIDENTAL_RANK_MAP = {
    "bbb": 0,
    "bb": 1,
    "b": 2,
    "": 3,  # natural
    "#": 4,
    "##": 5,
    "###": 6,
}


def _parse_note_name_components(name: str) -> tuple[str, str, str]:
    """
    Split a note name into (letter, accidental token(s), octave_part).
    Accepts a variety of accidental glyphs, music21 flats as '-' characters, and 'x' for double-sharp.
    """
    if not isinstance(name, str):
        return "", "", ""
    s = name.strip()
    if not s:
        return "", "", ""
    letter = s[0].upper() if s[0].upper() in {"A", "B", "C", "D", "E", "F", "G"} else ""
    if not letter:
        return "", "", ""
    idx = 1
    acc_raw = []
    while idx < len(s):
        ch = s[idx]
        if ch in {"#", "b", "-", "x", "♯", "♭", "𝄪", "𝄫", "♮"}:
            acc_raw.append(ch)
            idx += 1
        else:
            break
    octave_part = s[idx:] if idx < len(s) else ""
    return letter, "".join(acc_raw), octave_part


def _acc_raw_to_semitone_shift(acc_raw: str) -> int:
    """
    Convert a raw accidental string (which may contain '-', 'x', and glyphs) to a net semitone shift.
    Handles natural (♮) as a reset to zero.
    """
    if not acc_raw:
        return 0
    shift = 0
    natural_seen = False
    for ch in acc_raw:
        if ch in {"#", "♯"}:
            shift += 1
        elif ch in {"b", "-","♭"}:
            shift -= 1
        elif ch in {"x", "𝄪"}:
            shift += 2
        elif ch in {"𝄫"}:
            shift -= 2
        elif ch == "♮":
            # Natural cancels other accidentals in the same token
            shift = 0
            natural_seen = True
        else:
            continue
    # If natural appeared alone (or with others), we've already reset to 0
    return 0 if natural_seen else shift


def canonicalize_pitch_name(name: str, *, max_accidentals: int = 5) -> tuple[str, bool]:
    """
    Return a canonicalized pitch name where accidentals are expressed as repeated '#' or 'b',
    clamped to at most max_accidentals (default 5). Returns (canonical_name, exceeded_limit_flag).

    Examples:
      'Bb4' -> ('Bb4', False)
      'B-4' -> ('Bb4', False)
      'Fx5' -> ('F##5', False)
      'E#######6' -> ('E#####6', True)  # exceeds max 5, clamped and flagged
    """
    letter, acc_raw, octave_part = _parse_note_name_components(str(name))
    if not letter:
        # Return original string and no exceed flag; downstream callers can keep as-is
        return str(name), False
    shift = _acc_raw_to_semitone_shift(acc_raw)
    exceeded = abs(shift) > int(max_accidentals)
    if shift > 0:
        acc = "#" * min(int(max_accidentals), shift)
    elif shift < 0:
        acc = "b" * min(int(max_accidentals), -shift)
    else:
        acc = ""
    return f"{letter}{acc}{octave_part}", bool(exceeded)


def _acc_token_from_name(name: str) -> str:
    """
    Extract the accidental token (limited to triple range for ranking) from a pitch name.
    Returns one of {'bbb','bb','b','','#','##','###'} based on the net shift.
    """
    letter, acc_raw, _ = _parse_note_name_components(str(name))
    if not letter:
        return ""
    net = _acc_raw_to_semitone_shift(acc_raw)
    if net > 0:
        return "#" * min(3, net)
    if net < 0:
        return "b" * min(3, -net)
    return ""


def accidental_rank_from_name(name: str) -> int:
    """
    Compute the accidental rank using the fixed ordering:
      bbb < bb < b < natural < # < ## < ###
    Returns an integer in [0..6].
    """
    tok = _acc_token_from_name(name)
    return int(_ACCIDENTAL_RANK_MAP.get(tok, 3))


def _slugify_name(text: str) -> str:
    """
    Convert text to a filesystem and variable friendly slug: lowercase, alnum and underscores.
    """
    import re

    base = text.strip().lower()
    base = re.sub(r"[^a-z0-9]+", "_", base)
    base = re.sub(r"_+", "_", base)
    return base.strip("_")


def _source_to_name(file_source: str, index: int) -> str:
    """
    Build a stable name for a parsed file: 2-digit index + slugified basename without extension.
    Example: 00_wtc1f01
    """
    base = os.path.basename(file_source)
    if "/" in file_source or "\\" in file_source:
        # URLs will still work with basename; ensure we trim fragments/query
        base = base.split("?")[0].split("#")[0]
    stem, _ = os.path.splitext(base)
    return f"{index:02d}_" + _slugify_name(stem)


def parse_files(
    file_sources: Iterable[str],
    *,
    filter_zero_duration: bool = True,
    adjust_fractional_duration: bool = True,
    backend: str = "plt",
    show_measure_lines: bool = True,
    measure_line_color: str = "red",
    show_hover: bool = True,
    hover_fields: Optional[Union[Sequence[str], None]] = None,
    display_preview: bool = True,
    preview_rows: int = 20,
    cleanup_remote: bool = True,
    return_plots: bool = False,
    plot_width: Optional[int] = None,
    plot_height: Optional[int] = None,
    zoom_drag_dim: Optional[str] = None,
    zoom_wheel_dim: Optional[str] = None,
    show_progress: bool = True,
    progress_desc: Optional[str] = None,
    colorize_voices: bool = False,
    palette: Optional[Union[str, Sequence[str]]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, pd.DataFrame], Optional[pd.DataFrame]]:
    """
    Parse multiple symbolic music files, build DataFrames and optionally render piano rolls.

    Naming convention for multiple DataFrames: Each result is assigned a 'name'
    of the form 'NN_basename', where NN is a zero-padded index starting at 00
    and 'basename' is the slugified filename without extension. Use the returned
    'dfs_by_name' mapping for convenient access.

    Parameters
    ----------
    file_sources : Iterable[str]
        URLs or local file paths.
    filter_zero_duration : bool
        Remove rows with non-positive duration.
    adjust_fractional_duration : bool
        Round durations and onsets to 3 decimals.
    backend : {"plt", "bokeh", "none"}
        Plot backend for piano roll visualization. Use 'none' to disable plotting.
    show_measure_lines : bool
        If True, draw vertical red measure separation lines where measure offsets exist.
    display_preview : bool
        If True, display a small head() preview and summary for each file.
    preview_rows : int
        Number of rows to show in previews.
    cleanup_remote : bool
        If True, delete downloaded temp files after processing.
    return_plots : bool
        If True, include the created plot objects in results under 'plot'.
    plot_width : int, optional
        Width (pixels) for the Bokeh plot. If None, defaults to library default.
    plot_height : int, optional
        Height (pixels) for the Bokeh plot. If None, defaults to library default.
    zoom_drag_dim : {"width", "height", "both"}, optional
        Dimension for box zoom drag tool.
    zoom_wheel_dim : {"width", "height", "both"}, optional
        Dimension for wheel zoom tool.
    show_progress : bool
        If True and multiple files provided, show a tqdm progress bar. Default True.
    progress_desc : str, optional
        Custom description for the progress bar (default: "Parsing files").

    Returns
    -------
    (results, dfs_by_name, last_df)
        results: list of dicts with keys: name, source, df, measure_offsets[, plot]
        dfs_by_name: dict mapping name -> df
        last_df: the last successfully processed DataFrame (or None)
    """
    results: List[Dict[str, Any]] = []
    dfs_by_name: Dict[str, pd.DataFrame] = {}
    last_df: Optional[pd.DataFrame] = None

    # Lazy import to avoid hard dependency when used outside notebooks
    try:
        from IPython.display import display as ipy_display  # type: ignore
    except Exception:
        ipy_display = None  # Not in a notebook

    sources: List[str] = list(file_sources)
    use_progress = bool(show_progress) and (_tqdm is not None) and (len(sources) > 1)
    pbar = _tqdm(total=len(sources), desc=(progress_desc or "Parsing files"), unit="file") if use_progress else None
    log = (_tqdm.write if use_progress else print)

    try:
        for idx, file_source in enumerate(sources):
            try:
                name = _source_to_name(file_source, idx)
                short_name = os.path.basename(file_source).split("?")[0].split("#")[0]
                log(f"Processing: {short_name} -> {name}")
                if pbar is not None:
                    pbar.set_postfix_str(short_name)

                file_path = get_file_path(file_source)
                from music21 import converter as _converter
                score = _converter.parse(file_path)

                voice_data = extract_voice_data(score)
                df = pd.DataFrame(
                    voice_data,
                    columns=["Measure", "Local Onset", "Global Onset", "Duration", "Pitch", "Voice"],
                )
                df["MIDI"] = df["Pitch"].apply(lambda p: pitch_module.Pitch(p).midi)
                df = df[["Measure", "Local Onset", "Global Onset", "Duration", "Pitch", "MIDI", "Voice"]]
                df = df.sort_values("Global Onset").reset_index(drop=True)

                df_processed = filter_and_adjust_durations(
                    df,
                    filter_zero_duration=filter_zero_duration,
                    adjust_fractional_duration=adjust_fractional_duration,
                )
                df_processed = df_processed.sort_values("Global Onset").reset_index(drop=True)

                measure_offsets = get_measure_offsets(score)

                plot_obj = None
                if return_plots or backend != "none":
                    plot_obj = draw_piano_roll(
                        df_processed,
                        measure_offsets=measure_offsets,
                        backend=backend,
                        show_measure_lines=show_measure_lines,
                        measure_line_color=measure_line_color,
                        show_hover=show_hover,
                        hover_fields=hover_fields,
                        show=True,
                        plot_width=plot_width,
                        plot_height=plot_height,
                        zoom_drag_dim=zoom_drag_dim,
                        zoom_wheel_dim=zoom_wheel_dim,
                        colorize_voices=colorize_voices,
                        palette=palette,
                    )

                if display_preview and ipy_display is not None:
                    ipy_display(df_processed.head(preview_rows))
                    log(f"Rows: {len(df_processed)}, unique pitches: {df_processed['MIDI'].nunique()}")

                results.append({
                    "name": name,
                    "source": file_source,
                    "df": df_processed,
                    "measure_offsets": measure_offsets,
                    **({"plot": plot_obj} if return_plots else {}),
                })
                dfs_by_name[name] = df_processed
                last_df = df_processed

                if cleanup_remote and file_source.startswith(("http://", "https://")):
                    try:
                        os.remove(file_path)
                    except OSError:
                        pass

            except Exception as exc:
                log(f"An error occurred while processing {file_source}: {exc}")
            finally:
                if pbar is not None:
                    pbar.update(1)
    finally:
        if pbar is not None:
            pbar.close()

    return results, dfs_by_name, last_df
def _determine_resolution(
    df: pd.DataFrame,
    *,
    resolution_method: str = "auto",
    manual_resolution: Optional[float] = None,
) -> float:
    """
    Determine time resolution from the DataFrame and options.

    Parameters
    ----------
    df : pandas.DataFrame
        Must contain 'Duration'.
    resolution_method : {"auto", "manual", "standard"}
        How to pick resolution. 'auto' uses min positive duration; 'manual' uses provided value;
        'standard' uses a fixed musical grid (0.25 quarter notes).
    manual_resolution : float, optional
        Required when resolution_method == 'manual'.

    Returns
    -------
    float
        Positive resolution value.
    """
    method = (resolution_method or "auto").lower()
    if method == "auto":
        # Use smallest positive duration
        positive_durations = df.loc[df["Duration"] > 0, "Duration"]
        if positive_durations.empty:
            raise ValueError("No positive durations found to determine automatic resolution.")
        resolution = float(positive_durations.min())
    elif method == "manual":
        if manual_resolution is None:
            raise ValueError("manual_resolution must be provided when resolution_method is 'manual'.")
        resolution = float(manual_resolution)
    elif method == "standard":
        resolution = 0.25
    else:
        raise ValueError("Invalid resolution_method. Choose from 'auto', 'manual', 'standard'.")

    if resolution <= 0:
        raise ValueError("Resolution must be a positive number.")
    return resolution


def create_binary_matrix(
    df: pd.DataFrame,
    *,
    resolution_method: str = "auto",
    manual_resolution: Optional[float] = None,
    y_mode: str = "minmax",  # "full" | "minmax" | "chroma"
    midi_low: Optional[int] = None,
    midi_high: Optional[int] = None,
    row_order: str = "low_to_high",
) -> Tuple[np.ndarray, Dict[str, Any]]:
    """
    Convert a processed DataFrame to a binary piano-roll-like matrix.

    Parameters
    ----------
    df : pandas.DataFrame
        Must contain 'MIDI', 'Global Onset', and 'Duration'.
    resolution_method : {"auto", "manual", "standard"}
        Grid size to discretize time. See _determine_resolution.
    manual_resolution : float, optional
        Used when resolution_method == 'manual'.
    y_mode : {"full", "minmax", "chroma"}
        Controls the pitch axis:
        - "full": rows for all MIDI 0..127
        - "minmax": rows from min(df.MIDI)..max(df.MIDI) (or overridden by midi_low/high)
        - "chroma": 12 pitch classes (C..B), folding octaves
    midi_low : int, optional
        Override lower MIDI bound when y_mode == 'minmax'. Ignored otherwise.
    midi_high : int, optional
        Override upper MIDI bound when y_mode == 'minmax'. Ignored otherwise.
    row_order : {"low_to_high", "high_to_low"}
        Controls how MIDI rows are ordered in the matrix. "low_to_high" stores the
        lowest MIDI pitch at row 0 (matrix origin at the bottom). "high_to_low" stores
        the highest MIDI pitch at row 0 (matrix origin at the top).

    Returns
    -------
    (matrix, meta)
        matrix : np.ndarray of shape (num_pitches, num_cols), dtype=int
        meta : dict with keys: resolution, num_cols, time_end, y_mode, y_min, y_max,
               midi_low, midi_high, row_order, origin, pitch_class_labels (when chroma)
    """
    required_columns = {"MIDI", "Global Onset", "Duration"}
    missing = required_columns.difference(df.columns)
    if missing:
        raise ValueError(f"DataFrame is missing required columns: {sorted(missing)}")

    resolution = _determine_resolution(
        df,
        resolution_method=resolution_method,
        manual_resolution=manual_resolution,
    )

    # Determine time axis
    total_duration = float(df["Global Onset"].max() + df["Duration"].max())
    num_cols = int(np.ceil(total_duration / resolution))
    time_end = num_cols * resolution

    # Compute smallest positive duration in data for reporting
    positive_durations = df.loc[df["Duration"] > 0, "Duration"]
    min_positive_duration = float(positive_durations.min()) if not positive_durations.empty else 0.0

    # Determine pitch axis
    mode = (y_mode or "minmax").lower()
    if mode not in {"full", "minmax", "chroma"}:
        raise ValueError("y_mode must be one of {'full', 'minmax', 'chroma'}")

    row_order_normalized = (row_order or "low_to_high").lower()
    if row_order_normalized not in {"low_to_high", "high_to_low"}:
        raise ValueError("row_order must be 'low_to_high' or 'high_to_low'.")
    origin = "lower" if row_order_normalized == "low_to_high" else "upper"

    if mode == "full":
        y_min = 0
        y_max = 127
        num_rows = y_max - y_min + 1
        if row_order_normalized == "low_to_high":
            row_index_for_midi = lambda m: int(m) - y_min  # noqa: E731
        else:
            row_index_for_midi = lambda m: y_max - int(m)  # noqa: E731
        pitch_class_labels: Optional[List[str]] = None
        midi_low_final = y_min
        midi_high_final = y_max
    elif mode == "minmax":
        data_low = int(df["MIDI"].min())
        data_high = int(df["MIDI"].max())
        y_min = int(midi_low) if midi_low is not None else data_low
        y_max = int(midi_high) if midi_high is not None else data_high
        if y_min > y_max:
            y_min, y_max = y_max, y_min
        num_rows = y_max - y_min + 1
        if row_order_normalized == "low_to_high":
            row_index_for_midi = lambda m: int(m) - y_min  # noqa: E731
        else:
            row_index_for_midi = lambda m: y_max - int(m)  # noqa: E731
        pitch_class_labels = None
        midi_low_final = y_min
        midi_high_final = y_max
    else:  # chroma
        y_min = 0
        y_max = 11
        num_rows = 12
        if row_order_normalized == "low_to_high":
            row_index_for_midi = lambda m: int(m) % 12  # noqa: E731
            pitch_class_labels = [
                "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
            ]
        else:
            row_index_for_midi = lambda m: 11 - (int(m) % 12)  # noqa: E731
            pitch_class_labels = [
                "B", "A#", "A", "G#", "G", "F#", "F", "E", "D#", "D", "C#", "C",
            ]
        midi_low_final = None
        midi_high_final = None

    matrix = np.zeros((num_rows, num_cols), dtype=int)

    # Fill matrix
    for _, row in df.iterrows():
        midi_value = int(row["MIDI"])
        if mode != "chroma" and not (y_min <= midi_value <= y_max):
            # Ignore notes outside requested range
            continue
        r = row_index_for_midi(midi_value)
        start_col = int(np.floor(float(row["Global Onset"]) / resolution))
        end_col = int(np.ceil(float(row["Global Onset"] + row["Duration"]) / resolution))
        if end_col <= start_col:
            end_col = start_col + 1
        end_col = min(end_col, num_cols)
        if 0 <= r < num_rows:
            matrix[r, start_col:end_col] = 1

    meta: Dict[str, Any] = {
        "resolution": resolution,
        "num_cols": num_cols,
        "time_end": time_end,
        "total_duration": total_duration,
        "min_positive_duration": min_positive_duration,
        "y_mode": mode,
        "y_min": y_min,
        "y_max": y_max,
        "midi_low": midi_low_final,
        "midi_high": midi_high_final,
        "row_order": row_order_normalized,
        "origin": origin,
        **({"pitch_class_labels": pitch_class_labels} if pitch_class_labels is not None else {}),
    }

    return matrix, meta


def plot_binary_matrix(
    matrix: np.ndarray,
    meta: Dict[str, Any],
    *,
    backend: str = "plt",
    measure_offsets: Optional[List[float]] = None,
    show_measure_lines: bool = True,
    cmap: str = "gray_r",
    show: bool = True,
    plot_width: Optional[int] = None,
    plot_height: Optional[int] = None,
    dpi: float = 100.0,
    zoom_drag_dim: Optional[str] = None,
    zoom_wheel_dim: Optional[str] = None,
    pitch_labels: bool = True,
    save_html: Optional[bool] = None,
    save_html_path: Optional[str] = None,
    save_png_path: Optional[str] = None,
    open_html_after_save: bool = False,
) -> Any:
    """
    Visualize a binary matrix using Matplotlib or Bokeh, similar to draw_piano_roll.

    Parameters
    ----------
    matrix : np.ndarray
        Binary matrix (rows=pitches, cols=time steps).
    meta : dict
        Metadata returned by create_binary_matrix.
    backend : {"plt", "bokeh"}
        Plotting backend to use.
    measure_offsets : list[float], optional
        Global onset times where measures start. Drawn as vertical lines when provided.
    show_measure_lines : bool
        Whether to draw measure lines when offsets are provided.
    cmap : str
        Matplotlib colormap for imshow.
    show : bool
        Whether to immediately show the plot.
    plot_width : int, optional
        Width in pixels for both backends. If None, defaults to 900.
    plot_height : int, optional
        Height in pixels for both backends. If None, defaults to 600.
    dpi : float, optional
        DPI for matplotlib backend (used to convert pixels to inches). Default is 100.
    zoom_drag_dim : {"width", "height", "both"}, optional
        Dimension for box zoom drag tool (Bokeh only). None defaults to "both".
    zoom_wheel_dim : {"width", "height", "both"}, optional
        Dimension for wheel zoom tool (Bokeh only). None defaults to "both".
    pitch_labels : bool, optional
        For Bokeh: whether to use pitch names or indices on the y-axis when supported.
    save_html : bool, optional
        If None (default), behaves like legacy mode: saves HTML when save_html_path is provided.
        If True, saves HTML to save_html_path if provided, otherwise to "binary_matrix.html".
        If False, disables HTML saving regardless of save_html_path.
    save_html_path : str, optional
        When backend == 'bokeh', if provided (and save_html is True or None), saves the plot as standalone HTML.
    save_png_path : str, optional
        When backend == 'bokeh', if provided, attempts to export a PNG. Requires selenium and webdriver.
    open_html_after_save : bool, optional
        If True and HTML is saved, attempts to open it in a browser.

    Returns
    -------
    Any
        Backend-specific figure object.
    """
    backend = (backend or "plt").lower()

    # Set default dimensions if not provided
    width_pixels = plot_width or 900
    height_pixels = plot_height or 600

    time_end = float(meta["time_end"]) if "time_end" in meta else matrix.shape[1]
    y_min = int(meta.get("y_min", 0))
    y_max = int(meta.get("y_max", matrix.shape[0] - 1))
    y_mode = str(meta.get("y_mode", "minmax"))
    origin = str(meta.get("origin", "lower")).lower()
    if origin not in {"lower", "upper"}:
        origin = "lower"

    matrix_for_display = matrix if origin == "lower" else np.flipud(matrix)
    labels_display = meta.get("pitch_class_labels", [])
    if labels_display:
        labels_display = list(labels_display)
        if origin == "upper":
            labels_display.reverse()

    if backend == "plt":
        # Convert pixels to inches for matplotlib
        width_inches = width_pixels / dpi
        height_inches = height_pixels / dpi
        fig, ax = plt.subplots(figsize=(width_inches, height_inches))
        extent = [0, time_end, y_min, y_max]
        ax.imshow(
            matrix_for_display,
            aspect="auto",
            origin="lower",
            cmap=cmap,
            interpolation="nearest",
            extent=extent,
        )
        ax.set_xlabel("Time (in duration units)")
        ax.set_ylabel("Pitch Class" if y_mode == "chroma" else "MIDI Number")
        ax.set_title("Binary Matrix Representation")

        if y_mode == "chroma":
            ax.set_yticks(list(range(0, matrix_for_display.shape[0])))
            if labels_display:
                ax.set_yticklabels(labels_display)

        if show_measure_lines and measure_offsets is not None:
            for m_offset in measure_offsets:
                ax.axvline(x=m_offset, color="red", linestyle="--", linewidth=0.8)

        cbar = fig.colorbar(plt.cm.ScalarMappable(cmap=cmap), ax=ax, ticks=[0, 1])
        cbar.set_label("Note On/Off")
        fig.tight_layout()
        if show:
            plt.show()
        return fig

    if backend == "bokeh":
        try:
            from bokeh.plotting import figure, show as bokeh_show
            from bokeh.models import Span, LinearColorMapper, ColorBar, BasicTicker, BoxZoomTool, WheelZoomTool, PanTool
            # Initialize inline output in notebooks once
            global BOKEH_NOTEBOOK_INITIALIZED
            if not BOKEH_NOTEBOOK_INITIALIZED:
                try:
                    from IPython import get_ipython  # type: ignore
                    ip = get_ipython()
                except Exception:
                    ip = None
                if ip is not None:
                    try:
                        from bokeh.io import output_notebook
                        # Force inline resources to avoid CDN issues in restricted/offline environments
                        try:
                            from bokeh.resources import INLINE
                        except Exception:
                            INLINE = "inline"
                        output_notebook(hide_banner=True, resources=INLINE)
                        BOKEH_NOTEBOOK_INITIALIZED = True
                    except Exception:
                        pass
        except ImportError as exc:
            raise ImportError("Bokeh is not installed. Install bokeh to use the 'bokeh' backend.") from exc

        matrix_for_bokeh = np.ascontiguousarray(matrix_for_display)

        # Normalize zoom dimension options
        def _norm_dim(val: Optional[str]) -> str:
            if val is None:
                return "both"
            v = str(val).strip().lower()
            if v in {"x", "width"}:
                return "width"
            if v in {"y", "height"}:
                return "height"
            return "both"

        drag_dim = _norm_dim(zoom_drag_dim)
        wheel_dim = _norm_dim(zoom_wheel_dim)

        def _build_plot() -> Any:
            color_mapper = LinearColorMapper(palette=["#ffffff", "#000000"], low=0, high=1)
            rows, cols = matrix_for_bokeh.shape
            plot = figure(
                height=height_pixels,
                width=width_pixels,
                title="Binary Matrix Representation",
                x_axis_label="Time (in duration units)",
                y_axis_label=("Pitch Class" if y_mode == "chroma" else "MIDI Number"),
                tools="pan,reset,save",
            )
            try:
                plot.output_backend = "canvas"
            except Exception:
                pass

            # Ranges
            plot.y_range.start = y_min
            plot.y_range.end = y_min + rows
            plot.x_range.start = 0
            plot.x_range.end = time_end

            # Image
            plot.image(
                image=[matrix_for_bokeh],
                x=0,
                y=y_min,
                dw=time_end,
                dh=rows,
                color_mapper=color_mapper,
            )

            # Measure lines
            if show_measure_lines and measure_offsets is not None:
                for m_offset in measure_offsets:
                    plot.add_layout(Span(location=m_offset, dimension="height", line_color="red", line_dash="dashed", line_width=1))

            # Y-axis labels
            if y_mode == "chroma":
                if labels_display:
                    plot.yaxis.ticker = BasicTicker(desired_num_ticks=len(labels_display))
                    plot.yaxis.major_label_overrides = {i: lbl for i, lbl in enumerate(labels_display)}
            else:
                if pitch_labels and labels_display:
                    plot.yaxis.ticker = BasicTicker()
                    plot.yaxis.major_label_overrides = {i + y_min: lbl for i, lbl in enumerate(labels_display)}

            # Color bar
            color_bar = ColorBar(color_mapper=color_mapper, label_standoff=8, location=(0, 0))
            plot.add_layout(color_bar, "right")

            # Tools configuration (mirror draw_piano_roll)
            try:
                box_tool = BoxZoomTool(dimensions=drag_dim)
                wheel_tool = WheelZoomTool(dimensions=wheel_dim)
                plot.add_tools(box_tool, wheel_tool)
                pan_tool = plot.select_one(PanTool)
                if pan_tool is None:
                    pan_tool = PanTool()
                    plot.add_tools(pan_tool)
                plot.toolbar.active_drag = pan_tool
                plot.toolbar.active_scroll = wheel_tool
            except Exception:
                pass

            return plot

        # Build the plot used for display/return (complete plot)
        p = _build_plot()

        # Determine whether to save HTML (explicit toggle with back-compat)
        do_save_html = (bool(save_html) if save_html is not None else (save_html_path is not None))
        if do_save_html:
            try:
                from bokeh.embed import file_html  # type: ignore
                from bokeh.resources import INLINE  # type: ignore
                html = file_html(_build_plot(), INLINE, title="Binary Matrix")
                import io, os
                from datetime import datetime
                html_target = str(save_html_path) if save_html_path else "binary_matrix.html"
                abs_path = os.path.abspath(html_target)
                with io.open(abs_path, "w", encoding="utf-8") as f:
                    f.write(html)
                print(f"Data saved successfully to {abs_path} at {datetime.now().isoformat(timespec='seconds')}.")
                if open_html_after_save:
                    try:
                        import webbrowser
                        webbrowser.open_new_tab(abs_path)
                    except Exception:
                        pass
            except Exception as _exc:
                print(f"Warning: Failed to save HTML to '{save_html_path or 'binary_matrix.html'}': {_exc}")

        if save_png_path:
            try:
                from bokeh.io import export_png  # type: ignore
                import os
                from datetime import datetime
                abs_png = os.path.abspath(str(save_png_path))
                export_png(_build_plot(), filename=abs_png)
                print(f"Data saved successfully to {abs_png} at {datetime.now().isoformat(timespec='seconds')}.")
            except Exception as _exc:
                print(
                    "Warning: Failed to export PNG. Install 'selenium' and a webdriver (e.g., chromedriver). "
                    f"Error: {_exc}"
                )

        if show:
            bokeh_show(p)
        return p

    raise ValueError("Unsupported backend. Choose from 'plt' or 'bokeh'.")

def orient_binary_matrix(
    matrix: np.ndarray,
    meta: Dict[str, Any],
    *,
    target_origin: str = "lower",
) -> np.ndarray:
    """
    Return a binary matrix oriented for the requested origin.

    Parameters
    ----------
    matrix : np.ndarray
        Matrix returned by create_binary_matrix.
    meta : dict
        Metadata dictionary returned alongside the matrix.
    target_origin : {"lower", "upper"}
        Desired orientation. "lower" keeps the lowest MIDI pitch at the bottom;
        "upper" places the lowest MIDI pitch at the top.

    Returns
    -------
    np.ndarray
        Matrix oriented toward the requested origin. When a flip is required the
        returned view shares data via numpy.flipud.
    """
    current_origin = str(meta.get("origin", "lower")).lower()
    requested_origin = str(target_origin or "lower").lower()
    valid = {"lower", "upper"}
    if current_origin not in valid:
        current_origin = "lower"
    if requested_origin not in valid:
        raise ValueError("target_origin must be 'lower' or 'upper'.")
    if current_origin == requested_origin:
        return matrix
    return np.flipud(matrix)



# ----------------------------------------
# Binary matrix -> DataFrame (inverse ops)
# ----------------------------------------
def binary_matrix_to_df(
    matrix: np.ndarray,
    bottom_left_midi: int,
    resolution: float,
    *,
    increasing_upwards: bool = True,
) -> pd.DataFrame:
    """
    Parse a binary piano-roll matrix into a DataFrame using a bottom-left MIDI anchor and time resolution.

    Orientation:
    - If the matrix is a display/view (e.g., flipped with bottom being low MIDI), set increasing_upwards=True
      and pass bottom_left_midi = lowest MIDI (y_min).
    - If the matrix is a data matrix from create_binary_matrix (row 0 is low MIDI at the top),
      set increasing_upwards=False and pass bottom_left_midi = highest MIDI (y_max).

    Assumptions:
    - matrix shape is (num_rows, num_cols) where columns are time steps and rows are pitch bins.
    - The bottom-left cell corresponds to MIDI == bottom_left_midi at time 0.
    - Each column spans 'resolution' time units (e.g., quarterLength multiples).
    - Contiguous 1s in the same row form a note with Duration = (#cols) * resolution.
    """
    if resolution <= 0:
        raise ValueError("resolution must be a positive number")

    mat = np.asarray(matrix)
    if mat.ndim != 2:
        raise ValueError("matrix must be 2D (rows, cols)")

    rows, cols = mat.shape
    # Treat any positive value as 1
    mat_bin = (mat > 0).astype(int)

    midi_values: list[int] = []
    onsets: list[float] = []
    durations: list[float] = []

    # Iterate rows top-to-bottom in the array, but map to MIDI bottom-up
    for row_top in range(rows):
        row_from_bottom = rows - 1 - row_top
        if increasing_upwards:
            midi_value = int(bottom_left_midi + row_from_bottom)
        else:
            midi_value = int(bottom_left_midi - row_from_bottom)
        row_data = mat_bin[row_top]

        c = 0
        while c < cols:
            if row_data[c] == 1:
                start_c = c
                while c < cols and row_data[c] == 1:
                    c += 1
                end_c = c  # exclusive
                midi_values.append(midi_value)
                onsets.append(float(start_c * resolution))
                durations.append(float((end_c - start_c) * resolution))
            else:
                c += 1

    df = pd.DataFrame({
        "MIDI": midi_values,
        "Global Onset": onsets,
        "Duration": durations,
    })
    if len(df):
        df = df.sort_values(["Global Onset", "MIDI"]).reset_index(drop=True)
    return df


def _normalize_df(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    out["MIDI"] = out["MIDI"].astype(int)
    out["Global Onset"] = out["Global Onset"].astype(float).round(6)
    out["Duration"] = out["Duration"].astype(float).round(6)
    return out.sort_values(["Global Onset", "MIDI"]).reset_index(drop=True)


def binary_matrix_to_df_from_meta(matrix: np.ndarray, meta: dict, *, flipped: bool = False) -> pd.DataFrame:
    """
    Convenience wrapper to convert a matrix back to DataFrame using meta from create_binary_matrix.
    - flipped=False for raw data matrices (row 0 is lowest MIDI at the top)
    - flipped=True for views like np.flipud(matrix) where bottom is lowest MIDI
    """
    resolution = float(meta["resolution"])
    if flipped:
        return binary_matrix_to_df(
            matrix,
            bottom_left_midi=int(meta["y_min"]),
            resolution=resolution,
            increasing_upwards=True,
        )
    return binary_matrix_to_df(
        matrix,
        bottom_left_midi=int(meta["y_max"]),
        resolution=resolution,
        increasing_upwards=False,
    )


def binary_matrix_to_df_from_bounds(
    matrix: np.ndarray,
    *,
    resolution: float,
    midi_low: int | None = None,
    midi_high: int | None = None,
    flipped: bool = False,
) -> pd.DataFrame:
    """
    Convert without meta by specifying either midi_low (lowest MIDI) or midi_high (highest MIDI).
    - flipped=False for raw data matrices where row 0 is visually "top" and corresponds to lowest MIDI.
    - flipped=True for UI/display matrices where the bottom row is lowest MIDI (e.g., np.flipud view).
    Provide at least one of midi_low or midi_high. If one is missing, it will be inferred from the other.
    """
    rows = int(np.asarray(matrix).shape[0])
    if resolution <= 0:
        raise ValueError("resolution must be positive")

    if flipped:
        if midi_low is None and midi_high is None:
            raise ValueError("Provide midi_low or midi_high when flipped=True")
        if midi_low is None:
            midi_low = int(midi_high) - (rows - 1)
        return binary_matrix_to_df(matrix, bottom_left_midi=int(midi_low), resolution=resolution, increasing_upwards=True)

    # Not flipped: raw data orientation
    if midi_low is None and midi_high is None:
        raise ValueError("Provide midi_low or midi_high when flipped=False")
    if midi_high is None:
        midi_high = int(midi_low) + (rows - 1)
    return binary_matrix_to_df(matrix, bottom_left_midi=int(midi_high), resolution=resolution, increasing_upwards=False)


def round_trip_sanity_check(sample_df: pd.DataFrame, *, resolution: float = 0.5) -> tuple[bool, pd.DataFrame]:
    """
    Convert df -> binary (at resolution) -> df and report equality after normalization.
    Returns (ok, reconstructed_df).
    """
    mat, meta = create_binary_matrix(
        sample_df,
        resolution_method="manual",
        manual_resolution=resolution,
        y_mode="minmax",
    )
    # Try both orientations to be robust
    df_back_data = binary_matrix_to_df_from_meta(mat, meta, flipped=False)
    df_back_view = binary_matrix_to_df_from_meta(np.flipud(mat), meta, flipped=True)
    if _normalize_df(sample_df).equals(_normalize_df(df_back_data)):
        return True, df_back_data
    if _normalize_df(sample_df).equals(_normalize_df(df_back_view)):
        return True, df_back_view
    # Fallback: return the data-oriented reconstruction
    return False, df_back_data



# -----------------------------
# Notation parsing utilities
# -----------------------------
def parse_prototype_notation(notation: str) -> pd.DataFrame:
    """
    Parse a custom prototype notation string into a DataFrame with columns:
    'MIDI', 'Global Onset', 'Duration'.

    Token format: NOTE:ONSET:DURATION
      - NOTE: e.g., C4, D#5, Bb3
      - ONSET: float or fraction (e.g., 1.5 or 3/4)
      - DURATION: float or fraction
    Tokens can be separated by commas, semicolons, or newlines.
    """
    import re
    from fractions import Fraction
    from music21 import note as m21note

    pattern = re.compile(
        r"^\s*([A-Ga-g][#b]?-?\d+)\s*:\s*([\-+]?(?:\d+(?:\.\d+)?|\.\d+|\d+\/\d+))\s*:\s*([\-+]?(?:\d+(?:\.\d+)?|\.\d+|\d+\/\d+))\s*$"
    )

    def to_number(text: str) -> float:
        s = text.strip()
        return float(Fraction(s)) if "/" in s else float(s)

    tokens = [t.strip() for t in re.split(r"[\,\n;]+", notation) if t.strip()]

    midi_numbers = []
    onsets = []
    durations = []

    for tok in tokens:
        m = pattern.match(tok)
        if not m:
            raise ValueError(f"Invalid notation token: '{tok}' (expected NOTE:ONSET:DURATION)")
        note_name, onset_str, dur_str = m.groups()
        midi_num = m21note.Note(note_name).pitch.midi
        midi_numbers.append(int(midi_num))
        onsets.append(to_number(onset_str))
        durations.append(to_number(dur_str))

    prototype_df = pd.DataFrame({
        "MIDI": midi_numbers,
        "Global Onset": onsets,
        "Duration": durations,
    })
    if len(prototype_df):
        prototype_df = prototype_df.sort_values(["Global Onset", "MIDI"]).reset_index(drop=True)
    return prototype_df


def _stream_to_df(m21_stream) -> pd.DataFrame:
    """
    Convert a music21 Stream to a DataFrame with columns:
    'MIDI', 'Global Onset', 'Duration'.
    - Expands chords to individual pitches.
    - Merges ties if possible.
    """
    s = m21_stream
    try:
        s = s.stripTies(inPlace=False)
    except Exception:
        pass

    flat_stream = s.flatten()
    rows = []
    for el in flat_stream.notes:
        onset = float(el.offset)
        dur = float(el.duration.quarterLength)
        if getattr(el, "isChord", False):
            for p in el.pitches:
                rows.append((int(p.midi), onset, dur))
        else:
            rows.append((int(el.pitch.midi), onset, dur))

    df = pd.DataFrame(rows, columns=["MIDI", "Global Onset", "Duration"])
    if len(df):
        df = df.sort_values(["Global Onset", "MIDI"]).reset_index(drop=True)
    return df


def _preprocess_humdrum_text(text: str) -> str:
    """
    Normalize Humdrum/**kern text coming from triple-quoted, indented notebook cells:
    - Remove common indentation
    - Normalize newlines to \n
    - Trim leading/trailing spaces on each line
    - Drop empty lines
    - Ensure a trailing newline (some parsers expect it)
    """
    # Remove common indentation and normalize newlines
    normalized = textwrap.dedent(text).replace("\r\n", "\n").replace("\r", "\n")
    # Left/right trim each line and drop empties
    lines = [ln.strip() for ln in normalized.split("\n") if ln.strip()]
    result = "\n".join(lines)
    if not result.endswith("\n"):
        result += "\n"
    return result

def parse_notation(text: str, syntax: str, *, return_meta: bool = False) -> pd.DataFrame:
    """
    Unified notation parser.

    Parameters
    ----------
    text : str
        Notation text content.
    syntax : str
        One of {'prototype', 'tinynotation', 'humdrum', 'abc', 'lilypond'}.

    Returns
    -------
    pandas.DataFrame
        DataFrame with columns ['MIDI', 'Global Onset', 'Duration'] (quarterLength units).
    """
    from music21 import converter

    s = (syntax or "").strip().lower()
    fmt = None
    df: Optional[pd.DataFrame] = None

    if s in ("prototype", "proto", "custom"):
        fmt = "prototype"
        df = parse_prototype_notation(text)
    elif s in ("tinynotation", "tiny", "tn"):
        fmt = "tinynotation"
        m21 = converter.parse(f"tinynotation: {text}")
        df = _stream_to_df(m21)
    elif s in ("humdrum", "kern"):
        fmt = "humdrum"
        processed = _preprocess_humdrum_text(text)
        m21 = converter.parseData(processed, format="humdrum")
        df = _stream_to_df(m21)
    elif s == "abc":
        fmt = "abc"
        m21 = converter.parseData(text, format="abc")
        df = _stream_to_df(m21)
    elif s in ("lilypond", "ly", "lily"):
        fmt = "lilypond"
        try:
            m21 = converter.parseData(text, format="lilypond")
        except Exception as exc:
            raise RuntimeError(
                "Failed to parse LilyPond. Ensure LilyPond is installed and on PATH."
            ) from exc
        df = _stream_to_df(m21)
    else:
        raise ValueError(
            f"Unsupported syntax '{syntax}'. Choose from: prototype, tinynotation, humdrum, abc, lilypond"
        )

    if return_meta:
        return df, {"parsed_format": fmt}  # type: ignore[return-value]
    return df  # type: ignore[return-value]

    raise ValueError(
        f"Unsupported syntax '{syntax}'. Choose from: prototype, tinynotation, humdrum, abc, lilypond"
    )


def create_prototype_binary_matrix(
    prototype_df: pd.DataFrame,
    *,
    resolution: float = 0.5,
) -> Tuple[np.ndarray, Dict[str, Any]]:
    """
    Convert a prototype DataFrame into a binary matrix.

    This is a thin wrapper around create_binary_matrix using a manual resolution
    and the 'minmax' y-axis mode.

    Returns (matrix, meta) in the same structure as create_binary_matrix.
    """
    return create_binary_matrix(
        prototype_df,
        resolution_method="manual",
        manual_resolution=resolution,
        y_mode="minmax",
    )


def plot_prototype_binary_matrix(
    matrix: np.ndarray,
    meta: Dict[str, Any],
    *,
    backend: str = "plt",
    measure_offsets: Optional[List[float]] = None,
    show_measure_lines: bool = True,
    show: bool = True,
) -> Any:
    """
    Visualize a prototype binary matrix using the selected backend.
    """
    return plot_binary_matrix(
        matrix,
        meta,
        backend=backend,
        measure_offsets=measure_offsets,
        show_measure_lines=show_measure_lines,
        show=show,
    )

