import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.gridspec import GridSpec
from matplotlib.lines import Line2D


def format_date_range(df):
    """Return 'YYYY-MM-DD \u2013 YYYY-MM-DD' covering the mg/dL data in *df*.

    Returns 'N/A' when *df* has no rows.
    """
    if df.empty:
        return "N/A"
    first = df["Time"].min().strftime("%Y-%m-%d")
    last = df["Time"].max().strftime("%Y-%m-%d")
    return f"{first} \u2013 {last}"


def build_agp_profile(df, cfg):
    """Perform the circadian binning and return the result DataFrame."""
    BIN_MINUTES = cfg["BIN_MINUTES"]
    MIN_SAMPLES_PER_BIN = cfg["MIN_SAMPLES_PER_BIN"]

    df_work = df.copy()
    df_work["seconds"] = (
        df_work["Time"].dt.hour * 3600
        + df_work["Time"].dt.minute * 60
        + df_work["Time"].dt.second
    )
    df_work["bin"] = (df_work["seconds"] // (BIN_MINUTES * 60)).astype(int)

    grouped = df_work.groupby("bin")["Sensor Reading(mg/dL)"]

    result = grouped.agg(
        count="count",
        p5=lambda x: np.percentile(x, 5),
        p10=lambda x: np.percentile(x, 10),
        p25=lambda x: np.percentile(x, 25),
        median="median",
        mean="mean",
        p75=lambda x: np.percentile(x, 75),
        p90=lambda x: np.percentile(x, 90),
        p95=lambda x: np.percentile(x, 95),
    ).reset_index()

    roc_profile = df_work.groupby("bin")["ROC"].mean().reset_index()
    roc_profile.rename(columns={"ROC": "roc_mean"}, inplace=True)

    result = result.merge(roc_profile, on="bin", how="left")

    result.loc[
        result["count"] < MIN_SAMPLES_PER_BIN,
        result.columns.difference(["bin", "count"]),
    ] = np.nan

    result = result.sort_values("bin")
    result["minutes"] = result["bin"] * BIN_MINUTES

    return result


def _draw_daily_axes(ax, df, cfg, dark=False):
    """Draw daily overlay content onto *ax*.

    Draws glucose-range bands, threshold lines, per-day colored lines,
    axis labels, grid, night shading, and legend on the provided axes object.

    Args:
        ax: matplotlib Axes to draw on.
        df: Preprocessed glucose DataFrame with ``Time`` and
            ``Sensor Reading(mg/dL)`` columns.
        cfg: Configuration dict from :func:`build_config`.
        dark: When ``True``, apply dark-mode colour palette. Default: ``False``.
    """
    _fg = "#cdd6f4" if dark else "black"
    _grid_col = "#45475a" if dark else "gray"

    VERY_LOW = cfg["VERY_LOW"]
    LOW = cfg["LOW"]
    HIGH = cfg["HIGH"]
    VERY_HIGH = cfg["VERY_HIGH"]
    TIGHT_LOW = cfg["TIGHT_LOW"]
    TIGHT_HIGH = cfg["TIGHT_HIGH"]

    df = df.copy()
    df["date"] = df["Time"].dt.date
    df["minutes"] = (
        df["Time"].dt.hour * 60 + df["Time"].dt.minute + df["Time"].dt.second / 60
    )

    dates = sorted(df["date"].unique())
    cmap = plt.colormaps.get_cmap("tab20")
    colors = [cmap(i / max(len(dates), 1)) for i in range(len(dates))]

    # Shaded glucose-range bands
    ax.axhspan(
        TIGHT_LOW,
        TIGHT_HIGH,
        alpha=0.12,
        color="limegreen",
        label=f"Tight Target ({TIGHT_LOW}–{TIGHT_HIGH})",
    )
    ax.axhspan(
        TIGHT_HIGH,
        HIGH,
        alpha=0.10,
        color="darkgreen",
        label=f"Above Tight ({TIGHT_HIGH}–{HIGH})",
    )
    ax.axhspan(HIGH, 600, alpha=0.08, color="orange", label=f"Above Range (>{HIGH})")
    ax.axhspan(20, LOW, alpha=0.08, color="red", label=f"Below Range (<{LOW})")

    # Threshold reference lines
    ax.axhline(LOW, linestyle=":", linewidth=1, color="darkred", alpha=0.5)
    ax.axhline(HIGH, linestyle=":", linewidth=1, color="darkred", alpha=0.5)
    ax.axhline(VERY_LOW, linestyle=":", linewidth=1, color="maroon", alpha=0.4)
    ax.axhline(VERY_HIGH, linestyle=":", linewidth=1, color="darkorange", alpha=0.4)
    ax.axhline(TIGHT_HIGH, linestyle=":", linewidth=1, color="darkgreen", alpha=0.4)

    for date, color in zip(dates, colors):
        day_df = df[df["date"] == date].sort_values("minutes")
        ax.plot(
            day_df["minutes"],
            day_df["Sensor Reading(mg/dL)"],
            linewidth=1.2,
            alpha=0.8,
            color=color,
            label=str(date),
        )

    xticks = np.arange(0, 1441, 120)
    ax.set_xticks(xticks)
    ax.set_xticklabels([f"{int(t // 60):02d}:00" for t in xticks])
    ax.set_xlim(0, 1440)
    ax.set_ylim(20, max(400, df["Sensor Reading(mg/dL)"].max() + 20))

    ax.set_xlabel("Time of Day", fontsize=12)
    ax.set_ylabel("Glucose (mg/dL)", fontsize=12)
    ax.set_title(
        "Daily Glucose Overlay – each day as a separate line", fontsize=13, pad=10
    )
    ax.grid(True, alpha=0.25, color=_grid_col)

    ax.xaxis.label.set_color(_fg)
    ax.yaxis.label.set_color(_fg)
    ax.title.set_color(_fg)
    ax.tick_params(colors=_fg)
    for spine in ax.spines.values():
        spine.set_edgecolor(_grid_col)

    # Night shading
    ax.axvspan(22 * 60, 24 * 60, alpha=0.05, color="gray")
    ax.axvspan(0, 6 * 60, alpha=0.05, color="gray")

    # Build legend: day lines first, then range bands
    day_handles = [
        Line2D([0], [0], color=color, linewidth=1.5, label=str(date))
        for date, color in zip(dates, colors)
    ]
    band_handles, band_labels = ax.get_legend_handles_labels()
    # band_handles come from axhspan/axhline calls; keep only the named ones
    date_labels = {str(d) for d in dates}
    named = [
        (h, lbl)
        for h, lbl in zip(band_handles, band_labels)
        if not lbl.startswith("_") and lbl not in date_labels
    ]
    band_h = [h for h, _ in named]
    band_l = [lbl for _, lbl in named]

    ax.legend(
        day_handles + band_h,
        [str(d) for d in dates] + band_l,
        loc="upper right",
        fontsize=8,
        ncol=max(1, (len(dates) + 4) // 5),
        framealpha=0.9,
        title="Day / Range",
        title_fontsize=9,
    )


def _build_stats_text(metrics, cfg) -> str:
    """Build the monospaced stats string rendered in ax_stats."""
    LOW = cfg["LOW"]
    HIGH = cfg["HIGH"]
    VERY_LOW = cfg["VERY_LOW"]
    VERY_HIGH = cfg["VERY_HIGH"]
    TIGHT_LOW = cfg["TIGHT_LOW"]
    TIGHT_HIGH = cfg["TIGHT_HIGH"]

    def fmt(v, decimals=1):
        return f"{v:.{decimals}f}" if not np.isnan(v) else "N/A"

    tir = metrics["tir"]
    titr = metrics["titr"]
    tatr = metrics["tatr"]
    tar = metrics["tar"]
    tar_level1 = metrics["tar_level1"]
    tar_level2 = metrics["tar_level2"]
    tbr = metrics["tbr"]
    tbr_level1 = metrics["tbr_level1"]
    tbr_level2 = metrics["tbr_level2"]
    mean_glucose = metrics["mean_glucose"]
    median_glucose = metrics["median_glucose"]
    std_glucose = metrics["std_glucose"]
    mode_str = metrics["mode_str"]
    skew_glucose = metrics["skew_glucose"]
    skew_interpretation = metrics["skew_interpretation"]
    gmi = metrics["gmi"]
    cv_percent = metrics["cv_percent"]
    day_cv = metrics["day_cv"]
    night_cv = metrics["night_cv"]
    j_index = metrics["j_index"]
    mage = metrics["mage"]
    modd = metrics["modd"]
    conga = metrics["conga"]
    lbgi = metrics["lbgi"]
    hbgi = metrics["hbgi"]
    gri = metrics["gri"]
    gri_txt = metrics["gri_txt"]
    adrr = metrics["adrr"]
    time_weighted_avg = metrics["time_weighted_avg"]
    exposure_severity_to_hyperglycemia_pct = metrics[
        "exposure_severity_to_hyperglycemia_pct"
    ]
    exposure_severity_to_hypoglycemia_pct = metrics[
        "exposure_severity_to_hypoglycemia_pct"
    ]
    exposure_severity_to_severe_hypoglycemia_pct = metrics[
        "exposure_severity_to_severe_hypoglycemia_pct"
    ]
    days_of_data = metrics["days_of_data"]
    readings_per_day = metrics["readings_per_day"]
    wear_percentage = metrics["wear_percentage"]
    severe_hypo_per_week = metrics["severe_hypo_per_week"]
    p5 = metrics["p5"]
    p25 = metrics["p25"]
    p50 = metrics["p50"]
    p75 = metrics["p75"]
    p95 = metrics["p95"]
    iqr = metrics["iqr"]
    mag = metrics["mag"]
    gvp = metrics["gvp"]
    conga2 = metrics["conga2"]
    conga4 = metrics["conga4"]
    conga24 = metrics["conga24"]
    lability_index = metrics["lability_index"]
    cv_rate = metrics["cv_rate"]
    m_value = metrics["m_value"]
    ea1c = metrics["ea1c"]
    grade = metrics["grade"]
    grade_hypo_pct = metrics["grade_hypo_pct"]
    grade_eu_pct = metrics["grade_eu_pct"]
    grade_hyper_pct = metrics["grade_hyper_pct"]
    hypo_index = metrics["hypo_index"]
    hyper_index = metrics["hyper_index"]
    tir_by_hour = metrics.get("tir_by_hour", [np.nan] * 24)

    return (
        f"TIME IN RANGE\n"
        f"TIR ({LOW}-{HIGH}): {tir:.1f}%\n"
        f"TITR ({TIGHT_LOW}-{TIGHT_HIGH}): {titr:.1f}%  ← Tight Target\n"
        f"TATR ({TIGHT_HIGH}-{HIGH}): {tatr:.1f}%\n"
        f"TAR >{HIGH}: {tar:.1f}% ({HIGH + 1}-{VERY_HIGH}: {tar_level1:.1f}%, >{VERY_HIGH}: {tar_level2:.1f}%)\n"
        f"TBR <{LOW}: {tbr:.1f}% ({VERY_LOW}-{LOW - 1}: {tbr_level1:.1f}%, <{VERY_LOW}: {tbr_level2:.1f}%)\n\n"
        f"GLUCOSE STATS\n"
        f"Mean: {mean_glucose:.1f} mg/dL, median: {median_glucose:.1f} mg/dL\n"
        f"Std: {std_glucose:.1f} mg/dL, mode: {mode_str} mg/dL\n"
        f"skew: {skew_glucose:.1f} ({skew_interpretation})\n"
        f"GMI: {gmi:.2f}%\n"
        f"CV: {cv_percent:.1f}% {'(Stable)' if cv_percent < 36 else '(Unstable)'}\n"
        f"CV: Day: {fmt(day_cv)}%, Night: {fmt(night_cv)}%\n"
        f"J-Index: {j_index:.1f}\n\n"
        f"VARIABILITY\n"
        f"MAGE: {fmt(mage)}\n"
        f"MODD: {fmt(modd)}\n"
        f"CONGA(1h): {fmt(conga)}\n\n"
        f"RISK\n"
        f"LBGI: {lbgi:.2f}\n"
        f"HBGI: {hbgi:.2f}\n"
        f"GRI: {gri:.1f} ({gri_txt})\n"
        f"ADRR: {fmt(adrr)}\n\n"
        f"AUC\n"
        f"Time-weighted avg: {fmt(time_weighted_avg)} mg/dL\n"
        f"Hyperglycemia exposure severity: {exposure_severity_to_hyperglycemia_pct:.1f}%\n"
        f"Hypoglycemia exposure severity: {exposure_severity_to_hypoglycemia_pct:.1f}%\n"
        f"Severe hypoglycemia exposure severity: {exposure_severity_to_severe_hypoglycemia_pct:.1f}%\n\n"
        f"DATA QUALITY\n"
        f"Days: {days_of_data:.1f}\n"
        f"Readings/day: {readings_per_day:.0f}\n"
        f"Wear time: {wear_percentage:.1f}%\n"
        f"Severe hypo/week: {fmt(severe_hypo_per_week, 2)}\n\n"
        f"PERCENTILES\n"
        f"p5: {p5:.1f}  p25: {p25:.1f}  p50: {p50:.1f}\n"
        f"p75: {p75:.1f}  p95: {p95:.1f}  IQR: {iqr:.1f}\n\n"
        f"ADDITIONAL VARIABILITY\n"
        f"MAG: {fmt(mag)}  GVP: {fmt(gvp)}%\n"
        f"CONGA(2h): {fmt(conga2)}  CONGA(4h): {fmt(conga4)}  CONGA(24h): {fmt(conga24)}\n"
        f"Lability Index: {fmt(lability_index)} (mg/dL)^2/h,  CVrate: {fmt(cv_rate)}%\n"
        f"M-Value: {fmt(m_value)}\n\n"
        f"COMPOSITE INDICES\n"
        f"eA1c: {ea1c:.2f}%\n"
        f"GRADE: {fmt(grade)}  (Hypo: {fmt(grade_hypo_pct)}%  Eu: {fmt(grade_eu_pct)}%  Hyper: {fmt(grade_hyper_pct)}%)\n"
        f"Hypo Index: {fmt(hypo_index, 2)}  Hyper Index: {fmt(hyper_index, 2)}\n\n"
        f"HOURLY TIR (00→23):\n"
        + "|".join(str(int(v)) if not np.isnan(v) else "--" for v in tir_by_hour)
    )


def _make_figure_and_gridspec(heatmap: bool, daily_plot: bool):
    """Create and return (fig, gs) for the given panel combination."""
    if heatmap:
        if daily_plot:
            fig = plt.figure(figsize=(24, 21))
            gs = GridSpec(
                4,
                12,
                figure=fig,
                height_ratios=[3, 1.5, 1.5, 2],
                hspace=0.35,
                wspace=0.3,
            )
        else:
            fig = plt.figure(figsize=(24, 17))
            gs = GridSpec(
                3, 12, figure=fig, height_ratios=[3, 1.5, 1.5], hspace=0.35, wspace=0.3
            )
    else:
        if daily_plot:
            fig = plt.figure(figsize=(24, 16))
            gs = GridSpec(
                3, 12, figure=fig, height_ratios=[3, 1.5, 2], hspace=0.35, wspace=0.3
            )
        else:
            fig = plt.figure(figsize=(24, 12))
            gs = GridSpec(
                2, 12, figure=fig, height_ratios=[3, 1.5], hspace=0.35, wspace=0.3
            )
    return fig, gs


def _draw_glucose_bands(ax, cfg):
    """Draw background range bands and threshold lines onto ax."""
    TIGHT_LOW = cfg["TIGHT_LOW"]
    TIGHT_HIGH = cfg["TIGHT_HIGH"]
    HIGH = cfg["HIGH"]
    LOW = cfg["LOW"]

    ax.axhspan(TIGHT_LOW, TIGHT_HIGH, alpha=0.1, color="limegreen")
    ax.axhspan(TIGHT_HIGH, HIGH, alpha=0.07, color="green")
    ax.axhspan(HIGH, 600, alpha=0.07, color="orange")
    ax.axhspan(20, LOW, alpha=0.07, color="red")

    ax.axhline(LOW, linestyle=":", linewidth=1, color="darkred", alpha=0.4)
    ax.axhline(HIGH, linestyle=":", linewidth=1, color="darkred", alpha=0.4)
    ax.axhline(TIGHT_HIGH, linestyle=":", linewidth=1, color="darkgreen", alpha=0.4)


def _add_figure_texts(report_header: dict, date_range_str: str, fg_color: str):
    """Add header, subtitle, and copyright figtext annotations."""
    header_text = (
        f"Patient: {report_header['patient_name']} | ID: {report_header['patient_id']}"
    )
    if report_header["doctor"]:
        header_text += f" | Dr: {report_header['doctor']}"
    header_text += f" | Report Date: {report_header['report_date']}"

    plt.figtext(
        0.5,
        0.96,
        header_text,
        ha="center",
        fontsize=10,
        fontweight="bold",
        color=fg_color,
        bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray", alpha=0.3),
    )

    if report_header["notes"]:
        plt.figtext(
            0.5,
            0.94,
            f"Notes: {report_header['notes']} | Source: {report_header['data_source']} | Data range: {date_range_str}",
            ha="center",
            fontsize=8,
            style="italic",
            color=fg_color,
        )
    else:
        plt.figtext(
            0.5,
            0.94,
            f"Source: {report_header['data_source']} | Data range: {date_range_str}",
            ha="center",
            fontsize=8,
            style="italic",
            color=fg_color,
        )

    metadata = {
        "Source": "https://github.com/daedalus/agp",
        "Copyright": "Copyright 2026 Darío Clavijo",
        "License": "MIT License",
    }
    plt.figtext(
        0.5,
        0.02,
        f"{metadata['Source']}\n{metadata['Copyright']}\n{metadata['License']}",
        ha="center",
        fontsize=9,
        color=fg_color,
        style="italic",
        alpha=0.7,
    )


SCATTER_RANGES = [
    ("Tight Target", 8, 0.4, "none", None),
    ("Above Tight", 10, 0.5, "none", None),
    ("High", 12, 0.6, "none", None),
    ("Very High", 12, 0.7, "none", None),
    ("Low", 15, 0.8, "black", 0.5),
    ("Very Low", 20, 1.0, "black", 0.8),
]


def _resolve_theme(dark: bool) -> dict:
    """Return colour tokens for dark or light theme."""
    return {
        "bg": "#1e1e2e" if dark else "white",
        "fg": "#cdd6f4" if dark else "black",
        "grid_col": "#45475a" if dark else "gray",
        "box_fc": "#313244" if dark else "white",
        "box_ec": "#89b4fa" if dark else "gray",
        "box_alpha": 0.85 if dark else 0.35,
        "fig_fc": "#1e1e2e" if dark else "white",
    }


def _tag_glucose_ranges(df, cfg):
    """Return a copy of *df* with a ``glucose_range`` categorical column."""
    VERY_LOW = cfg["VERY_LOW"]
    TIGHT_LOW = cfg["TIGHT_LOW"]
    TIGHT_HIGH = cfg["TIGHT_HIGH"]
    HIGH = cfg["HIGH"]
    VERY_HIGH = cfg["VERY_HIGH"]
    df = df.copy()
    df["glucose_range"] = pd.cut(
        df["Sensor Reading(mg/dL)"],
        bins=[0, VERY_LOW, TIGHT_LOW, TIGHT_HIGH, HIGH, VERY_HIGH, 1000],
        labels=["Very Low", "Low", "Tight Target", "Above Tight", "High", "Very High"],
    )
    return df


def _draw_distribution_bar(ax_bar, metrics, cfg, theme: dict):
    """Draw the stacked glucose-distribution bar chart onto *ax_bar*."""
    VERY_LOW = cfg["VERY_LOW"]
    LOW = cfg["LOW"]
    HIGH = cfg["HIGH"]
    VERY_HIGH = cfg["VERY_HIGH"]
    TIGHT_LOW = cfg["TIGHT_LOW"]
    TIGHT_HIGH = cfg["TIGHT_HIGH"]

    percentages = [
        metrics["very_low_pct"],
        metrics["low_pct"],
        metrics["tight_target_pct"],
        metrics["above_tight_pct"],
        metrics["high_pct"],
        metrics["very_high_pct"],
    ]
    colors = ["darkred", "red", "limegreen", "yellowgreen", "orange", "darkorange"]
    labels = [
        f"Very Low (<{VERY_LOW})",
        f"Low ({VERY_LOW}-{LOW - 1})",
        f"Tight Target ({TIGHT_LOW}-{TIGHT_HIGH})",
        f"Above Tight ({TIGHT_HIGH + 1}-{HIGH})",
        f"High ({HIGH + 1}-{VERY_HIGH})",
        f"Very High (>{VERY_HIGH})",
    ]

    bottoms = np.zeros(1)
    bars = []
    for _, (pct, color) in enumerate(zip(percentages, colors)):
        if pct > 0:
            bar = ax_bar.bar(
                0,
                pct,
                bottom=bottoms[0],
                color=color,
                edgecolor="white",
                linewidth=1,
                width=0.5,
            )
            bars.append(bar)

            if pct > 3:
                y_pos = bottoms[0] + pct / 2
                ax_bar.text(
                    0,
                    y_pos,
                    f"{pct:.1f}%",
                    ha="center",
                    va="center",
                    color="white",
                    fontweight="bold",
                    fontsize=9,
                )
            bottoms[0] += pct

    ax_bar.set_ylim(0, 100)
    ax_bar.set_xlim(-0.5, 0.5)
    ax_bar.set_xticks([])
    ax_bar.set_ylabel("Percentage of Time (%)", fontsize=10)
    ax_bar.set_title("Glucose Distribution\nby Range", fontsize=11, pad=10)

    ax_bar.yaxis.grid(True, alpha=0.3, linestyle="--")
    ax_bar.set_axisbelow(True)

    legend_elements = []
    for _, (label, color, pct) in enumerate(zip(labels, colors, percentages)):
        if pct > 0:
            legend_elements.append(
                plt.Rectangle((0, 0), 1, 1, facecolor=color, edgecolor="white")
            )

    if legend_elements:
        ax_bar.legend(
            legend_elements,
            [label for label, pct in zip(labels, percentages) if pct > 0],
            loc="lower center",
            bbox_to_anchor=(0.5, 0.05),
            ncol=1,
            fontsize=8,
            frameon=True,
            fancybox=True,
            shadow=True,
            facecolor=theme["box_fc"],
            edgecolor="gray",
        )


def _draw_agp_panel(ax1, ax_stats, result, metrics, cfg, theme: dict):
    """Draw the main AGP panel and stats text box; return ``(ax1, ax2)``."""
    mean_glucose = metrics["mean_glucose"]
    trend_arrow = metrics["trend_arrow"]
    trend_color = metrics["trend_color"]

    x = result["minutes"]

    _draw_glucose_bands(ax1, cfg)

    ax1.axhline(
        mean_glucose,
        linestyle="-.",
        linewidth=2,
        color="purple",
        alpha=0.7,
        label="Overall Mean",
    )

    ax1.fill_between(
        x, result["p5"], result["p95"], alpha=0.15, color="blue", label="5–95%"
    )
    ax1.fill_between(
        x, result["p25"], result["p75"], alpha=0.35, color="blue", label="IQR"
    )
    ax1.plot(x, result["median"], linewidth=2.5, color="darkblue", label="Median")
    ax1.plot(
        x, result["mean"], linestyle="--", linewidth=1.5, color="navy", label="Mean"
    )

    ax1.axvspan(22 * 60, 24 * 60, alpha=0.05, color="gray")
    ax1.axvspan(0, 6 * 60, alpha=0.05, color="gray", label="Night Hours")

    ax1.set_xlabel("Time of Day", fontsize=12)
    ax1.set_ylabel("Glucose (mg/dL)", fontsize=12)
    ax1.grid(True, alpha=0.3, color=theme["grid_col"])

    ax2 = ax1.twinx()
    ax2.plot(
        x,
        result["roc_mean"],
        linestyle=":",
        linewidth=2,
        label="ROC (mg/dL/min)",
        color="orange",
    )
    ax2.set_ylabel("")

    xticks = np.arange(0, 1441, 120)
    ax1.set_xticks(xticks)
    ax1.set_xticklabels([f"{int(t//60):02d}:00" for t in xticks])

    current_ylim = ax1.get_ylim()
    ax1.set_ylim(current_ylim[0], current_ylim[1] * 1.15)

    textstr = _build_stats_text(metrics, cfg)
    ax_stats.axis("off")
    ax_stats.text(
        0.02,
        0.99,
        textstr,
        fontsize=6.6,
        verticalalignment="top",
        horizontalalignment="left",
        transform=ax_stats.transAxes,
        family="monospace",
        color=theme["fg"],
        bbox=dict(
            boxstyle="round",
            facecolor=theme["box_fc"],
            alpha=theme["box_alpha"],
            edgecolor=theme["box_ec"],
            linewidth=0.8,
            pad=0.5,
        ),
    )

    lines1, labels1 = ax1.get_legend_handles_labels()
    lines2, labels2 = ax2.get_legend_handles_labels()
    ax1.legend(
        lines1 + lines2,
        labels1 + labels2,
        loc="upper left",
        fontsize=9,
        bbox_to_anchor=(0.02, 0.98),
        ncol=2,
    )

    ax1.text(
        0.60,
        0.97,
        f"Overall Trend: {trend_arrow}",
        transform=ax1.transAxes,
        fontsize=12,
        fontweight="bold",
        color=trend_color,
        va="top",
        ha="center",
        bbox=dict(
            boxstyle="round,pad=0.3",
            facecolor=theme["bg"],
            alpha=0.7,
            edgecolor=trend_color,
            linewidth=1.5,
        ),
    )

    return ax1, ax2


def _draw_raw_scatter(ax3, df, metrics, cfg, theme: dict):
    """Draw the color-coded raw glucose scatter plot onto *ax3*."""
    days_of_data = metrics["days_of_data"]
    trend_arrow = metrics["trend_arrow"]
    trend_color = metrics["trend_color"]

    range_colors = {
        "Very Low": "darkred",
        "Low": "red",
        "Tight Target": "limegreen",
        "Above Tight": "yellowgreen",
        "High": "orange",
        "Very High": "darkorange",
    }

    for range_label, marker_size, alpha, edge_color, lw in SCATTER_RANGES:
        data = df[df["glucose_range"] == range_label]
        if data.empty:
            continue
        ax3.scatter(
            data["Time"],
            data["Sensor Reading(mg/dL)"],
            c=range_colors[range_label],
            s=marker_size,
            alpha=alpha,
            label=f"{range_label}: {len(data)} pts",
            edgecolors=edge_color,
            linewidth=lw if lw is not None else 0,
        )

    _draw_glucose_bands(ax3, cfg)

    ax3.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter("%Y-%m-%d"))
    ax3.xaxis.set_major_locator(
        plt.matplotlib.dates.DayLocator(interval=max(1, days_of_data // 7))
    )
    plt.setp(ax3.xaxis.get_majorticklabels(), rotation=45, ha="right", fontsize=9)

    if days_of_data <= 3:
        ax3.xaxis.set_minor_locator(plt.matplotlib.dates.HourLocator(interval=6))
        ax3.xaxis.set_minor_formatter(plt.matplotlib.dates.DateFormatter("%H:%M"))
        plt.setp(ax3.xaxis.get_minorticklabels(), rotation=45, ha="right", fontsize=8)

    ax3.set_ylabel("Glucose (mg/dL)", fontsize=11)
    ax3.set_xlabel("Date", fontsize=11)
    ax3.set_title("Raw Glucose Data Series (Color-coded by Range)", fontsize=12, pad=10)
    ax3.grid(True, alpha=0.2, color=theme["grid_col"])
    ax3.legend(loc="upper right", ncol=3, fontsize=8, framealpha=0.9)
    ax3.set_ylim(20, 400)

    ax3.text(
        0.01,
        0.97,
        f"Overall Trend: {trend_arrow}",
        transform=ax3.transAxes,
        fontsize=11,
        fontweight="bold",
        color=trend_color,
        va="top",
        ha="left",
        bbox=dict(
            boxstyle="round,pad=0.3",
            facecolor=theme["bg"],
            alpha=0.7,
            edgecolor=trend_color,
            linewidth=1.5,
        ),
    )


def _draw_heatmap(ax_heat, df, args):
    """Draw the circadian glucose heatmap onto *ax_heat*."""
    heat_df = df[["Time", "Sensor Reading(mg/dL)"]].copy()
    heat_df["hour"] = heat_df["Time"].dt.hour
    heat_df["date"] = heat_df["Time"].dt.date
    heat_data = heat_df.pivot_table(
        index="date", columns="hour", values="Sensor Reading(mg/dL)", aggfunc="mean"
    )
    heat_data = heat_data.reindex(columns=range(24))

    heat_img = ax_heat.imshow(
        heat_data.values,
        aspect="auto",
        cmap=args.heatmap_cmap,
        vmin=40,
        vmax=300,
        interpolation="nearest",
    )

    cbar = plt.colorbar(heat_img, ax=ax_heat, pad=0.01)
    cbar.set_label("Glucose (mg/dL)", fontsize=10)

    ax_heat.set_xticks(range(24))
    ax_heat.set_xticklabels(
        [f"{h:02d}:00" for h in range(24)], fontsize=8, rotation=45, ha="right"
    )
    ax_heat.set_xlabel("Hour of Day", fontsize=10)

    date_labels = [str(d) for d in heat_data.index]
    ax_heat.set_yticks(range(len(date_labels)))
    ax_heat.set_yticklabels(date_labels, fontsize=8)
    ax_heat.set_ylabel("Date", fontsize=10)

    WAKE_HOUR = 6
    SLEEP_HOUR = 22
    ax_heat.axvline(
        x=WAKE_HOUR - 0.5, color="white", linestyle="--", linewidth=1.2, alpha=0.8
    )
    ax_heat.axvline(
        x=SLEEP_HOUR - 0.5, color="white", linestyle="--", linewidth=1.2, alpha=0.8
    )

    ax_heat.set_title(
        "Circadian Glucose Heatmap (Mean mg/dL per Hour)", fontsize=12, pad=10
    )


def _apply_theme_to_figure(fig, theme: dict):
    """Apply dark/light theme colours to all axes in *fig*."""
    fig.patch.set_facecolor(theme["fig_fc"])
    for _ax in fig.get_axes():
        _ax.set_facecolor(theme["bg"])
        _ax.tick_params(colors=theme["fg"])
        _ax.xaxis.label.set_color(theme["fg"])
        _ax.yaxis.label.set_color(theme["fg"])
        _ax.title.set_color(theme["fg"])
        for spine in _ax.spines.values():
            spine.set_edgecolor(theme["grid_col"])


def _choose_suptitle(heatmap: bool, daily_plot: bool) -> str:
    """Return the appropriate figure suptitle string."""
    if heatmap:
        if daily_plot:
            return (
                "Ambulatory Glucose Profile with Time in Tight Range (TITR), "
                "Raw Data Series, Circadian Heatmap and Daily Overlay"
            )
        return (
            "Ambulatory Glucose Profile with Time in Tight Range (TITR), "
            "Raw Data Series and Circadian Heatmap"
        )
    if daily_plot:
        return (
            "Ambulatory Glucose Profile with Time in Tight Range (TITR), "
            "Raw Data Series and Daily Overlay"
        )
    return (
        "Ambulatory Glucose Profile with Time in Tight Range (TITR) "
        "and Raw Data Series"
    )


def _save_and_finish(fig, theme: dict, args, output_path, show, close):
    """Save, optionally show, and optionally close the figure."""
    metadata = {
        "Description": "Ambulatory Glucose Profile generated by AGP tool",
        "Source": "https://github.com/daedalus/agp",
        "Copyright": "Copyright 2026 Darío Clavijo",
        "License": "MIT License",
    }

    _save_path = (
        output_path if output_path is not None else getattr(args, "output", None)
    )
    if _save_path:
        plt.savefig(
            _save_path,
            dpi=300,
            bbox_inches="tight",
            metadata=metadata,
            facecolor=theme["fig_fc"],
        )
        if getattr(args, "verbose", False):
            print(f"Plot saved to: {_save_path}")
    if show:
        plt.show()
    if close:
        plt.close()


def generate_agp_plot(
    df,
    result,
    metrics,
    cfg,
    args,
    report_header,
    *,
    output_path=None,
    show=False,
    close=False,
    daily_plot=False,
):
    """Render the full AGP figure and return it.

    Args:
        df: Preprocessed glucose DataFrame.
        result: AGP profile DataFrame from :func:`build_agp_profile`.
        metrics: Metrics dict from :func:`compute_all_metrics`.
        cfg: Configuration dict from :func:`build_config`.
        args: argparse Namespace (or None) supplying ``heatmap``,
            ``heatmap_cmap``, ``output``, and ``verbose`` attributes.
        report_header: Report header dict from :func:`create_report_header`.
        output_path: If given, save the figure to this path (overrides
            ``args.output``).  Pass ``None`` (and ensure ``args.output`` is
            also ``None`` / falsy) to skip saving entirely.
        show: If ``True`` call ``plt.show()`` after building the figure.
        close: If ``True`` call ``plt.close()`` after building the figure.
        daily_plot: If ``True`` add an extra daily overlay panel at the bottom
            of the figure.  Default: ``False``.

    Returns:
        matplotlib.figure.Figure: The completed AGP figure.
    """
    dark = getattr(args, "dark_mode", False)
    theme = _resolve_theme(dark)
    df = _tag_glucose_ranges(df, cfg)

    fig, gs = _make_figure_and_gridspec(
        heatmap=getattr(args, "heatmap", False), daily_plot=daily_plot
    )

    # --- TOP ROW ---
    ax_bar = fig.add_subplot(gs[0, :2])
    ax1 = fig.add_subplot(gs[0, 2:9])
    ax_stats = fig.add_subplot(gs[0, 9:])

    _draw_distribution_bar(ax_bar, metrics, cfg, theme)
    _draw_agp_panel(ax1, ax_stats, result, metrics, cfg, theme)

    # --- BOTTOM ROW ---
    ax3 = fig.add_subplot(gs[1, :])
    _draw_raw_scatter(ax3, df, metrics, cfg, theme)

    # --- HEATMAP ROW ---
    if getattr(args, "heatmap", False):
        ax_heat = fig.add_subplot(gs[2, :])
        _draw_heatmap(ax_heat, df, args)

    # --- DAILY ROW ---
    if daily_plot:
        daily_row = 3 if getattr(args, "heatmap", False) else 2
        ax_daily = fig.add_subplot(gs[daily_row, :])
        _draw_daily_axes(ax_daily, df, cfg, dark=dark)

    # --- FINALISE ---
    date_range_str = format_date_range(df)
    _add_figure_texts(report_header, date_range_str, theme["fg"])
    plt.suptitle(
        _choose_suptitle(getattr(args, "heatmap", False), daily_plot),
        fontsize=14,
        y=0.92,
        color=theme["fg"],
    )
    plt.tight_layout()
    _apply_theme_to_figure(fig, theme)
    _save_and_finish(fig, theme, args, output_path, show, close)
    return fig


def generate_daily_plot(
    df,
    cfg,
    args,
    report_header,
    *,
    output_path=None,
    show=False,
    close=False,
):
    """Render a daily overlay plot and return it.

    Each calendar day in *df* is drawn as a separate line in a distinct color,
    with time-of-day (00:00–24:00) on the X-axis so that day-to-day patterns
    can be compared directly.

    Args:
        df: Preprocessed glucose DataFrame with ``Time`` and
            ``Sensor Reading(mg/dL)`` columns.
        cfg: Configuration dict from :func:`build_config`.
        args: argparse Namespace (or None) supplying ``output`` and ``verbose``
            attributes.
        report_header: Report header dict from :func:`create_report_header`.
        output_path: If given, save the figure to this path.  Pass ``None``
            (and ensure ``args.output`` is also ``None`` / falsy) to skip
            saving entirely.
        show: If ``True`` call ``plt.show()`` after building the figure.
        close: If ``True`` call ``plt.close()`` after building the figure.

    Returns:
        matplotlib.figure.Figure: The completed daily overlay figure.
    """
    dark = getattr(args, "dark_mode", False)
    _fg_d = "#cdd6f4" if dark else "black"
    _fig_fc = "#1e1e2e" if dark else "white"

    fig, ax = plt.subplots(figsize=(14, 7))

    _draw_daily_axes(ax, df, cfg, dark=dark)

    # Header
    date_range_str = format_date_range(df)
    header_text = (
        f"Patient: {report_header['patient_name']} | ID: {report_header['patient_id']}"
    )
    if report_header["doctor"]:
        header_text += f" | Dr: {report_header['doctor']}"
    header_text += f" | Report Date: {report_header['report_date']}"

    plt.figtext(
        0.5,
        0.97,
        header_text,
        ha="center",
        fontsize=10,
        fontweight="bold",
        color=_fg_d,
        bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray", alpha=0.3),
    )
    plt.figtext(
        0.5,
        0.01,
        f"Source: {report_header['data_source']} | Data range: {date_range_str}",
        ha="center",
        fontsize=8,
        style="italic",
        color=_fg_d,
    )

    plt.tight_layout(rect=[0, 0.03, 1, 0.95])

    # Apply facecolors AFTER tight_layout so they are not reset
    fig.patch.set_facecolor(_fig_fc)
    ax.set_facecolor(_fig_fc)

    metadata = {
        "Description": "Daily Glucose Overlay generated by AGP tool",
        "Source": "https://github.com/daedalus/agp",
        "Copyright": "Copyright 2026 Darío Clavijo",
        "License": "MIT License",
    }

    _save_path = (
        output_path
        if output_path is not None
        else getattr(args, "daily_plot_output", None)
    )
    if _save_path:
        plt.savefig(
            _save_path,
            dpi=300,
            bbox_inches="tight",
            metadata=metadata,
            facecolor=_fig_fc,
        )
        if getattr(args, "verbose", False):
            print(f"Daily plot saved to: {_save_path}")
    if show:
        plt.show()
    if close:
        plt.close()
    return fig
