"""Visualization tools for CRISPRi library output.

Provides genome-wide plots (matplotlib) and gene-level text inspection
functions for verifying guide RNA designs.
"""

import base64
import html as html_module
import io
import random

import numpy as np

from crisprbact.off_target import FEATURE_TYPES
from crisprbact.predict import SCORE_BOUNDARIES
from crisprbact.utils import rev_comp


_HTML_BASE_CSS = """\
  body { font-family: sans-serif; max-width: 1100px; margin: 0 auto; padding: 20px; }
  h1 { color: #333; }
  h2 { color: #555; border-bottom: 1px solid #ccc; padding-bottom: 5px; }
  pre { background: #f5f5f5; padding: 15px; border-radius: 5px;
        overflow-x: auto; font-size: 0.85em; }
  img { display: block; margin: 10px 0; }
  .plot-row { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 10px; }
  .plot-cell { flex: 1; min-width: 0; }\
"""


def _require_matplotlib():
    try:
        import matplotlib

        matplotlib.use("Agg")
        import matplotlib.pyplot as plt

        return plt
    except ImportError:
        raise ImportError(
            "matplotlib is required for plotting. "
            "Install it with: pip install crisprbact[viz]"
        )


# ---------------------------------------------------------------------------
# Genome-wide plot functions
# ---------------------------------------------------------------------------


def plot_score_distribution(library):
    """Histogram of on-target scores with random-guide quartile boundaries.

    Vertical lines show the quartile boundaries from ``predict.SCORE_BOUNDARIES``
    (computed from 1000 random sequences). A good library should be shifted to
    the right.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library(), must have 'score' column.

    Returns
    -------
    matplotlib.figure.Figure
    """
    plt = _require_matplotlib()
    fig, ax = plt.subplots(figsize=(8, 5))
    scores = library["score"].values

    ax.hist(scores, bins=50, edgecolor="black", alpha=0.7)

    q1, q2, q3 = SCORE_BOUNDARIES
    for val, label in [(q1, "Q1/Q2"), (q2, "Q2/Q3"), (q3, "Q3/Q4")]:
        ax.axvline(val, color="red", linestyle="--", alpha=0.7)
        ax.text(
            val, ax.get_ylim()[1] * 0.95,
            f" {label}", fontsize=8, color="red",
        )

    ax.set_xlabel("On-target score")
    ax.set_ylabel("Number of guides")
    ax.set_title("Score distribution (dashed lines = random-guide quartile boundaries)")
    fig.tight_layout()
    return fig


def plot_guides_per_gene(library):
    """Histogram of number of guides per gene.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library(), must have 'locus_tag' column.

    Returns
    -------
    matplotlib.figure.Figure
    """
    plt = _require_matplotlib()
    fig, ax = plt.subplots(figsize=(8, 5))

    counts = library.groupby("locus_tag").size()
    vc = counts.value_counts().sort_index()
    ax.bar(vc.index, vc.values, width=0.8, edgecolor="black", alpha=0.7)

    ax.set_xlabel("Number of guides per gene")
    ax.set_ylabel("Number of genes")
    ax.set_title("Guides per gene distribution")
    fig.tight_layout()
    return fig


def plot_off_target_distribution(library):
    """Histogram of off-target counts per guide.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library(), must have 'ntargets' column.

    Returns
    -------
    matplotlib.figure.Figure
    """
    plt = _require_matplotlib()
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    axes[0].hist(library["ntargets"].values, bins=50, edgecolor="black", alpha=0.7)
    axes[0].set_xlabel("Total off-targets (ntargets)")
    axes[0].set_ylabel("Number of guides")
    axes[0].set_title("ntargets distribution")

    if "noff_12" in library.columns:
        axes[1].hist(library["noff_12"].values, bins=50, edgecolor="black", alpha=0.7)
        axes[1].set_xlabel("12-mer off-targets (noff_12)")
        axes[1].set_ylabel("Number of guides")
        axes[1].set_title("noff_12 distribution")

    fig.tight_layout()
    return fig


def plot_genome_coverage(library, records):
    """Scatter plot of guide positions across the genome.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library(), must have 'pos' and 'score' columns.
    records : list of SeqRecord
        Parsed genome records.

    Returns
    -------
    matplotlib.figure.Figure
    """
    plt = _require_matplotlib()
    n_recs = len(records)
    fig, axes = plt.subplots(
        n_recs, 1, figsize=(14, 3 * n_recs), squeeze=False
    )

    for i, rec in enumerate(records):
        ax = axes[i, 0]
        rec_lib = library[library["recid"] == rec.id]
        if len(rec_lib) > 0:
            ax.scatter(
                rec_lib["pos"].values,
                rec_lib["score"].values,
                s=1,
                alpha=0.3,
            )
        ax.set_xlim(0, len(rec.seq))
        ax.set_xlabel("Genome position")
        ax.set_ylabel("Score")
        ax.set_title(f"Coverage: {rec.id}")

    fig.tight_layout()
    return fig


# ---------------------------------------------------------------------------
# Gene-level text inspection functions
# ---------------------------------------------------------------------------


def _find_gene_feature(records, query):
    """Find a gene feature by locus_tag or gene name."""
    for rec in records:
        for feat in rec.features:
            if feat.type != "gene":
                continue
            quals = feat.qualifiers
            locus_tag = quals.get("locus_tag", [None])[0]
            gene_name = quals.get("gene", [None])[0]
            if query in (locus_tag, gene_name):
                return rec, feat
    return None, None


def inspect_gene(library, records, query, context=10):
    """Text-based view of guides targeting a gene with sequence context.

    Shows the coding strand (5'->3') with PAM (CCN) in lowercase and the
    guide RNA sequence. The guide RNA binds the coding strand (non-template)
    for CRISPRi. On the coding strand, the PAM appears as CCN (complement
    of NGG on the template strand), immediately upstream of the guide
    binding site.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library().
    records : list of SeqRecord
        Parsed genome records.
    query : str
        Gene name or locus_tag to inspect.
    context : int
        Number of bases to show on each side of the guide+PAM.

    Returns
    -------
    str
        Formatted text showing gene info and guide alignments.
    """
    rec, feat = _find_gene_feature(records, query)
    if feat is None:
        return f"Gene '{query}' not found in records."

    quals = feat.qualifiers
    locus_tag = quals.get("locus_tag", [None])[0]
    gene_name = quals.get("gene", ["N/A"])[0]
    gene_start = int(feat.location.start)
    gene_end = int(feat.location.end)
    gene_strand = feat.location.strand
    gene_len = gene_end - gene_start

    # Find guides for this gene
    gene_guides = library[
        (library["locus_tag"] == locus_tag)
        | (library["gene"] == query)
    ].sort_values("gene_rank")

    if len(gene_guides) == 0:
        return f"No guides found for gene '{query}' (locus_tag={locus_tag})."

    lines = []
    strand_str = "+" if gene_strand == 1 else "-"
    lines.append(
        f"Gene: {gene_name} | locus_tag: {locus_tag} | "
        f"{gene_start}-{gene_end} ({strand_str}) | length: {gene_len} bp"
    )
    lines.append("=" * 80)

    genome_seq = str(rec.seq)
    genome_len = len(genome_seq)

    for _, row in gene_guides.iterrows():
        rank = int(row["gene_rank"])
        guide_seq = row["guide"]
        guide_strand = row["strand"]
        pam_pos = int(row["pos"])
        score = row["score"]
        ntargets = int(row.get("ntargets", 0))
        noff_12 = int(row.get("noff_12", 0))

        lines.append("")
        lines.append(
            f"Guide #{rank} | score: {score:.3f} | strand: {guide_strand} | "
            f"pos: {pam_pos} | ntargets: {ntargets} | noff_12: {noff_12}"
        )
        lines.append(f"  guide: 5'-{guide_seq}-3'")
        lines.append("-" * 80)

        # Determine region of + strand to extract.
        # pam_pos is the genome (+) strand coordinate of the N in NGG.
        #
        # For guide_strand "+": on + strand, [guide 20nt][NGG]
        #   guide at [pam_pos-20, pam_pos), NGG at [pam_pos, pam_pos+3)
        #
        # For guide_strand "-": on + strand, [CCN'][guide_RC 20nt]
        #   CCN' at [pam_pos-2, pam_pos+1), guide_RC at [pam_pos+1, pam_pos+21)
        if guide_strand == "+":
            region_start = max(0, pam_pos - 20 - context)
            region_end = min(genome_len, pam_pos + 3 + context)
        else:
            region_start = max(0, pam_pos - 2 - context)
            region_end = min(genome_len, pam_pos + 21 + context)

        plus_region = genome_seq[region_start:region_end]

        # Get coding strand (5'->3')
        if gene_strand == 1:
            coding = plus_region
            coord_start = region_start - gene_start
        else:
            coding = rev_comp(plus_region)
            coord_start = gene_end - region_end

        # Find the guide's reverse complement on the coding strand.
        # On the coding strand, the target always appears as:
        #   5'- ...CCN [rev_comp(guide)]... -3'
        # because the PAM (NGG) is on the template strand.
        guide_rc = rev_comp(guide_seq)
        idx = coding.find(guide_rc)

        if idx is not None and idx >= 3:
            _format_coding_strand(
                lines, coding, idx, idx + 20, idx - 3, idx,
                coord_start, guide_seq,
            )
        else:
            lines.append(f"  coding 5'-{coding}-3'")
            lines.append("  (could not locate guide pattern)")

    lines.append("")
    return "\n".join(lines)


def _format_coding_strand(
    lines, coding_seq, guide_start, guide_end, pam_start, pam_end,
    start_coord, guide_seq,
):
    """Format the coding strand with PAM highlighted and guide RNA aligned.

    Shows the coding strand 5'->3', base-pairing indicators, and the guide
    RNA 3'->5' aligned underneath. The PAM (CCN) is shown in lowercase.

    Parameters
    ----------
    coding_seq : str
        Coding strand sequence, 5'->3'.
    guide_start, guide_end : int
        Offsets of the guide binding region (rev_comp of guide RNA).
    pam_start, pam_end : int
        Offsets of the CCN PAM on the coding strand.
    start_coord : int
        Gene-relative coordinate of the first base in the display.
    guide_seq : str
        The guide RNA sequence (5'->3').
    """
    # PAM in lowercase
    chars = list(coding_seq)
    for i in range(max(0, pam_start), min(len(chars), pam_end)):
        chars[i] = chars[i].lower()
    coding_display = "".join(chars)

    # Base-pairing line: pipes under the guide region
    pairing = list(" " * len(coding_seq))
    for i in range(max(0, guide_start), min(len(pairing), guide_end)):
        pairing[i] = "|"
    pairing_display = "".join(pairing)

    # Guide RNA displayed 3'->5' (reversed), aligned under guide region
    guide_reversed = guide_seq[::-1]  # 3'->5'
    guide_line = list(" " * len(coding_seq))
    for j, c in enumerate(guide_reversed):
        idx = guide_start + j
        if 0 <= idx < len(guide_line):
            guide_line[idx] = c
    guide_display = "".join(guide_line)

    end_coord = start_coord + len(coding_seq)
    pad = 12

    lines.append(f"{'':>{pad}}{start_coord:<30}{end_coord}")
    lines.append(f"  coding 5'-{coding_display}-3'")
    lines.append(f"{'':>{pad}}{pairing_display}")
    lines.append(f"  guide  3'-{guide_display}-5'")
    lines.append(f"{'':>{12}}{'':>{pam_start}}PAM")


def _fig_to_base64(fig):
    """Render a matplotlib Figure to a base64-encoded PNG string."""
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
    buf.seek(0)
    plt = _require_matplotlib()
    plt.close(fig)
    return base64.b64encode(buf.read()).decode("ascii")


def _img_tag(b64):
    """Return an <img> tag for a base64-encoded PNG."""
    return f'<img src="data:image/png;base64,{b64}" style="max-width:100%;" />'


def _plot_row(pairs):
    """Return HTML for one or more plots arranged side by side.

    Parameters
    ----------
    pairs : list of (title, b64) tuples
        One element → full-width. Two elements → side-by-side flex row.
    """
    if len(pairs) == 1:
        title, b64 = pairs[0]
        return f"<h2>{html_module.escape(title)}</h2>\n{_img_tag(b64)}\n"
    cells = "".join(
        f'<div class="plot-cell"><h2>{html_module.escape(t)}</h2>{_img_tag(b)}</div>'
        for t, b in pairs
    )
    return f'<div class="plot-row">{cells}</div>\n'


def _all_gene_info(records):
    """Return a dict of locus_tag -> (gene_name, size_bp, function) for all genes.

    Function is extracted from the matching CDS feature if available.
    """
    # First pass: collect function from CDS features
    cds_functions = {}
    for rec in records:
        for feat in rec.features:
            if feat.type == "CDS":
                lt = feat.qualifiers.get("locus_tag", [None])[0]
                if lt is not None:
                    func = feat.qualifiers.get("function", [None])[0]
                    if func is None:
                        func = feat.qualifiers.get("product", [None])[0]
                    cds_functions[lt] = func

    # Second pass: collect gene features
    genes = {}
    for rec in records:
        for feat in rec.features:
            if feat.type != "gene":
                continue
            quals = feat.qualifiers
            lt = quals.get("locus_tag", [None])[0]
            if lt is not None:
                name = quals.get("gene", ["N/A"])[0]
                size = int(feat.location.end) - int(feat.location.start)
                func = cds_functions.get(lt)
                genes[lt] = (name, size, func)
    return genes


def generate_report(library, records, n, output_path):
    """Generate a self-contained HTML report for a CRISPRi library.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library().
    records : list of SeqRecord
        Parsed genome records.
    n : int
        Requested number of guides per gene.
    output_path : str or path-like
        Path to write the HTML report.
    """
    # --- Collect statistics ---
    all_genes = _all_gene_info(records)
    total_genes = len(all_genes)
    total_genome_bp = sum(len(rec.seq) for rec in records)
    n_records = len(records)

    covered_tags = set(library["locus_tag"].unique())
    n_covered = len(covered_tags)
    total_guides = len(library)

    guides_per_gene = library.groupby("locus_tag").size()

    # Score statistics
    scores = library["score"].values
    _q1, _q2, _q3 = SCORE_BOUNDARIES
    pct_q1 = (scores >= _q1).sum() / len(scores) * 100
    pct_q2 = ((scores >= _q2) & (scores < _q1)).sum() / len(scores) * 100
    pct_q3 = ((scores >= _q3) & (scores < _q2)).sum() / len(scores) * 100
    pct_q4 = (scores < _q3).sum() / len(scores) * 100

    # Off-target statistics
    unique_pct = (library["ntargets"] == 1).sum() / total_guides * 100

    # Bad seeds
    badseed_count = library["inbadseeds"].sum()
    badseed_pct = badseed_count / total_guides * 100

    # Position bias
    second_half_count = library["second_half_gene"].sum()
    second_half_pct = second_half_count / total_guides * 100

    # Genome name
    genome_name = records[0].description if records else "Unknown"

    # --- Build statistics text ---
    stats_lines = []

    stats_lines.append("GENOME SUMMARY")
    stats_lines.append(f"  Records:      {n_records}")
    stats_lines.append(f"  Genome size:  {total_genome_bp:,} bp")
    stats_lines.append(f"  Total genes:  {total_genes}")
    stats_lines.append("")

    stats_lines.append("LIBRARY SUMMARY")
    stats_lines.append(f"  Total guides:   {total_guides}")
    stats_lines.append(f"  Genes covered:  {n_covered} / {total_genes}")
    stats_lines.append(f"  Requested N:    {n} guides per gene")
    stats_lines.append("")

    stats_lines.append("SCORE STATISTICS")
    stats_lines.append(f"  Min:    {scores.min():.4f}")
    stats_lines.append(f"  Max:    {scores.max():.4f}")
    stats_lines.append(f"  Mean:   {scores.mean():.4f}")
    stats_lines.append(f"  Median: {np.median(scores):.4f}")
    stats_lines.append(
        f"  Quartile boundaries (random-guide reference): "
        f"Q1 > {_q1:.4f} > Q2 > {_q2:.4f} > Q3 > {_q3:.4f} > Q4"
    )
    stats_lines.append(f"  % Q1 (score ≥ {_q1:.4f}):           {pct_q1:.1f}%")
    stats_lines.append(f"  % Q2 ({_q2:.4f} ≤ score < {_q1:.4f}):   {pct_q2:.1f}%")
    stats_lines.append(f"  % Q3 ({_q3:.4f} ≤ score < {_q2:.4f}): {pct_q3:.1f}%")
    stats_lines.append(f"  % Q4 (score < {_q3:.4f}):          {pct_q4:.1f}%")
    stats_lines.append("")

    stats_lines.append("OFF-TARGET STATISTICS")
    stats_lines.append(f"  Unique guides (ntargets=1): {unique_pct:.1f}%")
    stats_lines.append(f"  Mean ntargets:  {library['ntargets'].mean():.2f}")
    stats_lines.append(f"  Max ntargets:   {library['ntargets'].max()}")
    if "noff_12" in library.columns:
        stats_lines.append(f"  Mean noff_12:   {library['noff_12'].mean():.2f}")
        stats_lines.append(f"  Max noff_12:    {library['noff_12'].max()}")
    stats_lines.append("")

    stats_lines.append("BAD SEEDS")
    stats_lines.append(f"  Flagged guides: {badseed_count} ({badseed_pct:.1f}%)")
    stats_lines.append("")

    stats_lines.append("POSITION BIAS")
    stats_lines.append(
        f"  Guides in 2nd half of gene: {second_half_count} ({second_half_pct:.1f}%)"
    )

    stats_text = "\n".join(stats_lines)

    # --- Coverage warnings: genes with 3 or fewer guides, sorted by count ---
    warn_lines = []
    poorly_covered = []
    for lt, (name, size, func) in all_genes.items():
        cnt = int(guides_per_gene.get(lt, 0))
        if cnt <= 3:
            poorly_covered.append((cnt, lt, name, size, func))
    poorly_covered.sort(key=lambda x: (x[0], x[1]))

    if poorly_covered:
        warn_lines.append(
            f"GENES WITH 3 OR FEWER GUIDES ({len(poorly_covered)}):"
        )
        for cnt, lt, name, size, func in poorly_covered:
            line = f"  {lt}  ({name}): {cnt} guides, {size} bp"
            if func:
                line += f" - {func}"
            warn_lines.append(line)

    warnings_text = "\n".join(warn_lines) if warn_lines else "No coverage warnings."

    # --- Generate plots ---
    score_b64 = _fig_to_base64(plot_score_distribution(library))
    gpg_b64 = _fig_to_base64(plot_guides_per_gene(library))
    off_b64 = _fig_to_base64(plot_off_target_distribution(library))
    cov_b64 = _fig_to_base64(plot_genome_coverage(library, records))

    # --- Gene inspection examples (one + strand, one - strand) ---
    inspect_html = ""
    if "gene_ori" in library.columns:
        plus_genes = library[library["gene_ori"] == 1]["locus_tag"].unique()
        minus_genes = library[library["gene_ori"] == -1]["locus_tag"].unique()
        examples = []
        if len(plus_genes) > 0:
            examples.append(random.choice(plus_genes))
        if len(minus_genes) > 0:
            examples.append(random.choice(minus_genes))
        for lt in examples:
            text = inspect_gene(library, records, lt)
            inspect_html += (
                f"<h3>{html_module.escape(lt)}</h3>\n"
                f"<pre>{html_module.escape(text)}</pre>\n"
            )

    # --- Build HTML ---
    plot_html = (
        _plot_row([("Score distribution", score_b64), ("Guides per gene", gpg_b64)])
        + _plot_row([("Off-target distribution", off_b64)])
        + _plot_row([("Genome coverage", cov_b64)])
    )

    html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>CRISPRi Library Report - {genome_name}</title>
<style>
{_HTML_BASE_CSS}
</style>
</head>
<body>
<h1>CRISPRi Library Report</h1>
<p><strong>Genome:</strong> {genome_name}</p>

<h2>Library Statistics</h2>
<pre>{stats_text}</pre>

<h2>Coverage Warnings</h2>
<pre>{warnings_text}</pre>

{plot_html}

<h2>Gene Inspection Examples</h2>
<p>Random sample: one gene on each strand.</p>
{inspect_html}

</body>
</html>"""

    with open(output_path, "w") as f:
        f.write(html)


# ---------------------------------------------------------------------------
# Core-genome report helpers
# ---------------------------------------------------------------------------


def _plot_core_score_distribution(library):
    """Histogram of mean on-target scores for core library guides."""
    plt = _require_matplotlib()
    score_col = "mean_score" if "mean_score" in library.columns else "score"
    fig, ax = plt.subplots(figsize=(8, 5))
    scores = library[score_col].values
    ax.hist(scores, bins=50, edgecolor="black", alpha=0.7)
    ax.set_xlabel("Mean on-target score (across strains)")
    ax.set_ylabel("Number of guides")
    ax.set_title("Mean score distribution")
    fig.tight_layout()
    return fig


def _plot_gps_distribution(library):
    """Histogram of GPS (Global Penalty Score) values."""
    plt = _require_matplotlib()
    fig, ax = plt.subplots(figsize=(8, 5))
    ax.hist(library["gps"].values, bins=50, edgecolor="black", alpha=0.7)
    ax.set_xlabel("GPS (lower = better)")
    ax.set_ylabel("Number of guides")
    ax.set_title("GPS distribution")
    fig.tight_layout()
    return fig


def _plot_coverage_distribution(library):
    """Histogram of per-guide genome coverage fractions."""
    plt = _require_matplotlib()
    fig, ax = plt.subplots(figsize=(8, 5))
    ax.hist(library["coverage"].values, bins=20, edgecolor="black", alpha=0.7,
            range=(0, 1))
    ax.set_xlabel("Coverage (fraction of strains)")
    ax.set_ylabel("Number of guides")
    ax.set_title("Guide coverage distribution")
    fig.tight_layout()
    return fig


def _plot_per_genome_families(genome_ids, per_genome_covered, core_families):
    """Horizontal bar chart: number of core families covered per genome."""
    plt = _require_matplotlib()
    n_total = len(core_families)
    counts = [len(per_genome_covered.get(gid, set())) for gid in genome_ids]
    fig, ax = plt.subplots(figsize=(10, max(4, len(genome_ids) * 0.4 + 1)))
    y = range(len(genome_ids))
    ax.barh(list(y), counts, color="steelblue", alpha=0.8)
    ax.axvline(n_total, color="red", linestyle="--", alpha=0.7, label="Total core families")
    ax.set_yticks(list(y))
    ax.set_yticklabels(genome_ids, fontsize=8)
    ax.set_xlabel("Families covered by guides")
    ax.set_title("Per-genome families covered")
    ax.legend()
    fig.tight_layout()
    return fig


def _plot_guides_per_family(library):
    """Histogram: number of guides selected per family."""
    plt = _require_matplotlib()
    counts = library.groupby("family_id").size()
    vc = counts.value_counts().sort_index()
    fig, ax = plt.subplots(figsize=(8, 5))
    ax.bar(vc.index, vc.values, width=0.8, edgecolor="black", alpha=0.7)
    ax.set_xlabel("Guides per family")
    ax.set_ylabel("Number of families")
    ax.set_title("Guides per family distribution")
    fig.tight_layout()
    return fig


def _plot_gene_type_breakdown(all_records, genome_ids):
    """Stacked horizontal bar chart of CDS / ncRNA / rRNA / tRNA per genome."""
    plt = _require_matplotlib()
    types = FEATURE_TYPES
    colors = ["#4c72b0", "#dd8452", "#55a868", "#c44e52"]

    data = {t: [] for t in types}
    for gid in genome_ids:
        counts = {t: 0 for t in types}
        for rec in all_records[gid]:
            for feat in rec.features:
                if feat.type in counts:
                    counts[feat.type] += 1
        for t in types:
            data[t].append(counts[t])

    fig, ax = plt.subplots(figsize=(10, max(4, len(genome_ids) * 0.4 + 1)))
    y = list(range(len(genome_ids)))
    lefts = [0] * len(genome_ids)
    for t, color in zip(types, colors):
        vals = data[t]
        ax.barh(y, vals, left=lefts, label=t, color=color, alpha=0.8)
        lefts = [l + v for l, v in zip(lefts, vals)]

    ax.set_yticks(y)
    ax.set_yticklabels(genome_ids, fontsize=8)
    ax.set_xlabel("Number of features")
    ax.set_title("Gene-type breakdown per genome")
    ax.legend(loc="lower right")
    fig.tight_layout()
    return fig


def generate_core_report(
    library, all_records, genome_ids, core_families,
    per_genome_covered, n, output_path, gene_name_map=None
):
    """Generate a self-contained HTML report for a core-genome CRISPRi library.

    Parameters
    ----------
    library : DataFrame
        Output of generate_core_library().
    all_records : dict
        {genome_id: list of SeqRecord}.
    genome_ids : list of str
        Ordered list of genome IDs.
    core_families : dict
        {family_id: {genome_id: [locus_tags]}} as returned by find_core_families().
    per_genome_covered : dict
        {genome_id: set of family_ids covered by at least one selected guide}.
    n : int
        Requested number of guides per family.
    output_path : str or path-like
        Path to write the HTML report.
    gene_name_map : dict or None
        Pre-computed {family_id: gene_name} mapping. When None, recomputed
        from all_records (slower path for standalone calls).
    """
    import html as html_module

    n_genomes = len(genome_ids)
    n_core_families = len(core_families)
    families_in_library = set(library["family_id"].unique()) if len(library) else set()
    n_families_with_guides = len(families_in_library)
    n_families_no_guides = n_core_families - n_families_with_guides
    total_guides = len(library)

    # --- Section 1: Summary banner ---
    summary_lines = [
        "RUN PARAMETERS / SUMMARY",
        f"  Genomes:                   {n_genomes}",
        f"  Core families found:       {n_core_families}",
        f"  Families with guides:      {n_families_with_guides}",
        f"  Families without guides:   {n_families_no_guides}",
        f"  Guides requested (n):      {n}",
        f"  Total guides in library:   {total_guides}",
    ]
    if len(library):
        score_col = "mean_score" if "mean_score" in library.columns else "score"
        scores = library[score_col].values
        score_label = "Mean score" if score_col == "mean_score" else "Score"
        summary_lines += [
            "",
            "GUIDE QUALITY STATISTICS",
            f"  {score_label} min/max/mean/median: "
            f"{scores.min():.3f} / {scores.max():.3f} / "
            f"{scores.mean():.3f} / {np.median(scores):.3f}",
        ]
        if "gps" in library.columns:
            gps = library["gps"].values
            summary_lines.append(
                f"  GPS    min/max/mean/median: "
                f"{gps.min():.3f} / {gps.max():.3f} / "
                f"{gps.mean():.3f} / {np.median(gps):.3f}"
            )
        if "coverage" in library.columns:
            cov = library["coverage"].values
            summary_lines.append(
                f"  Coverage min/max/mean/median: "
                f"{cov.min():.3f} / {cov.max():.3f} / "
                f"{cov.mean():.3f} / {np.median(cov):.3f}"
            )
        if "off_11_gene_score" in library.columns:
            summary_lines.append(
                f"  off_11_gene_score mean:    "
                f"{library['off_11_gene_score'].mean():.3f}"
            )
        if "off_9_prom_score" in library.columns:
            summary_lines.append(
                f"  off_9_prom_score mean:     "
                f"{library['off_9_prom_score'].mean():.3f}"
            )
        if "inbadseeds" in library.columns:
            bs_count = int(library["inbadseeds"].sum())
            bs_pct = bs_count / total_guides * 100
            summary_lines.append(
                f"  Bad seeds:                 {bs_count} ({bs_pct:.1f}%)"
            )
        if "coverage" in library.columns:
            max_cov_count = int((library["coverage"] == 1.0).sum())
            max_cov_pct = max_cov_count / total_guides * 100
            summary_lines.append(
                f"  Guides at max coverage:    {max_cov_count} ({max_cov_pct:.1f}%)"
            )
        # 4th-guide rescue: families where n_selected > n
        if len(library):
            per_fam = library.groupby("family_id").size()
            rescue_count = int((per_fam > n).sum())
            summary_lines.append(
                f"  Families with 4th-rescue:  {rescue_count}"
            )

    summary_text = "\n".join(summary_lines)

    # --- Section 2: Per-strain table ---
    feature_types = FEATURE_TYPES

    def _count_features(records, feat_type):
        return sum(1 for rec in records for f in rec.features if f.type == feat_type)

    def _genome_size_mbp(records):
        return sum(len(rec.seq) for rec in records) / 1e6

    def _core_members_count(gid):
        return sum(1 for fam in core_families.values() if gid in fam)

    table_rows = []
    for gid in genome_ids:
        recs = all_records[gid]
        size = _genome_size_mbp(recs)
        feat_counts = {t: _count_features(recs, t) for t in feature_types}
        core_members = _core_members_count(gid)
        covered = len(per_genome_covered.get(gid, set()))
        table_rows.append((gid, size, feat_counts, core_members, covered))

    feat_headers = "".join(f"<th>{t}</th>" for t in feature_types)
    table_html = (
        "<table border='1' cellpadding='5' cellspacing='0' "
        "style='border-collapse:collapse; font-size:0.85em;'>\n"
        f"<tr>"
        f"<th>Genome ID</th><th>Size (Mbp)</th>"
        f"{feat_headers}"
        f"<th>Core families (members)</th>"
        f"<th>Families covered by guides</th>"
        f"</tr>\n"
    )
    for gid, size, fc, core_m, covered in table_rows:
        feat_cells = "".join(f"<td>{fc[t]}</td>" for t in feature_types)
        table_html += (
            f"<tr>"
            f"<td>{html_module.escape(gid)}</td>"
            f"<td>{size:.2f}</td>"
            f"{feat_cells}"
            f"<td>{core_m}</td>"
            f"<td>{covered}</td>"
            f"</tr>\n"
        )
    table_html += "</table>"

    # --- Section 4: Families without guides ---
    if gene_name_map is None:
        from crisprbact.core_library import _family_gene_names
        gene_name_map = _family_gene_names(core_families, all_records)

    missed_families = [
        fid for fid in core_families if fid not in families_in_library
    ]
    if missed_families:
        missed_lines = [f"FAMILIES WITHOUT GUIDES ({len(missed_families)}):"]
        for fid in missed_families:
            members = core_families[fid]
            gids_in_fam = list(members.keys())
            gene_name = gene_name_map.get(fid)
            gene_str = f" [{gene_name}]" if gene_name else ""
            missed_lines.append(
                f"  {fid}{gene_str}  ({len(gids_in_fam)} genomes): "
                + ", ".join(html_module.escape(g) for g in gids_in_fam)
            )
        missed_text = "\n".join(missed_lines)
    else:
        missed_text = "All core families have at least one guide."

    # --- Section 5: Plots ---
    score_b64 = gps_b64 = cov_b64 = fam_b64 = gpf_b64 = None
    if len(library):
        score_b64 = _fig_to_base64(_plot_core_score_distribution(library))
        if "gps" in library.columns:
            gps_b64 = _fig_to_base64(_plot_gps_distribution(library))
        if "coverage" in library.columns:
            cov_b64 = _fig_to_base64(_plot_coverage_distribution(library))
        fam_b64 = _fig_to_base64(
            _plot_per_genome_families(genome_ids, per_genome_covered, core_families)
        )
        gpf_b64 = _fig_to_base64(_plot_guides_per_family(library))
    gtype_b64 = _fig_to_base64(_plot_gene_type_breakdown(all_records, genome_ids))

    # --- Build HTML ---
    plot_html = ""
    # Score + GPS side by side (both histograms of similar size)
    if score_b64 and gps_b64:
        plot_html += _plot_row([("Score distribution", score_b64), ("GPS distribution", gps_b64)])
    elif score_b64:
        plot_html += _plot_row([("Score distribution", score_b64)])
    # Coverage + Guides per family side by side
    if cov_b64 and gpf_b64:
        plot_html += _plot_row([
            ("Guide coverage distribution", cov_b64), ("Guides per family", gpf_b64)
        ])
    elif cov_b64:
        plot_html += _plot_row([("Guide coverage distribution", cov_b64)])
    elif gpf_b64:
        plot_html += _plot_row([("Guides per family", gpf_b64)])
    # Full-width horizontal bar charts (height varies with number of genomes)
    if fam_b64:
        plot_html += _plot_row([("Per-genome families covered", fam_b64)])
    plot_html += _plot_row([("Gene-type breakdown per genome", gtype_b64)])

    glossary_text = """\
METRICS GLOSSARY

Score
  Prediction of on-target CRISPRi activity from a linear model trained on
  genome-wide screens in E. coli (Calvo-Villamañán et al. 2020). Higher
  values indicate stronger predicted repression. In the core library CSV the
  value shown is the mean across all strain matches (mean_score); per-strain
  CSVs contain the individual strain values.

GPS — Global Penalty Score
  A composite ranking metric that balances three normalised penalties:

    GPS = off_score_norm + eff_score_norm + cov_score

  Where:
    off_score_norm  = normalised(off_11_gene_score + off_9_prom_score)
                      Fraction of genomes with ≥1 seed match in a gene (11-nt)
                      or promoter region (9-nt), normalised 0–1 within the
                      candidate set for this family.
    eff_score_norm  = normalised(1 − score)
                      Penalises low-activity guides, normalised 0–1.
    cov_score       = 1 − coverage
                      Penalises guides that do not cover all strains (0–1,
                      no normalisation needed).

  Lower GPS = better guide. Normalisation is performed within the candidate
  set for each family, so GPS values are not comparable across families.

How GPS and score are used during guide selection
  For each core gene family the pipeline:
    1. Finds all NGG-PAM candidate guides across all member genes in all strains.
    2. Keeps only guides near maximal genome coverage.
    3. Computes GPS for each unique guide sequence.
    4. Selects the top N guides ranked by GPS (ascending).
    5. Rescue heuristic: if the collective coverage of the top N guides is
       below the coverage_threshold, a (N+1)-th guide is added.
  Score is used as a tiebreaker when deduplicating guide sequences across
  strains before GPS ranking (the row with the highest score is kept as the
  representative).\
"""

    html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>CRISPRi Core-Genome Library Report</title>
<style>
{_HTML_BASE_CSS}
  body {{ max-width: 1200px; }}
  table {{ margin: 10px 0; }}
  .warn {{ background: #fff8e1; border-left: 4px solid #f9a825; }}
  .gloss {{ background: #e8f5e9; border-left: 4px solid #43a047; }}
</style>
</head>
<body>
<h1>CRISPRi Core-Genome Library Report</h1>

<h2>Run Parameters &amp; Summary</h2>
<pre>{html_module.escape(summary_text)}</pre>

<h2>Metrics Glossary</h2>
<pre class="gloss">{html_module.escape(glossary_text)}</pre>

<h2>Per-Strain Table</h2>
{table_html}

<h2>Families Without Guides</h2>
<pre class="warn">{html_module.escape(missed_text)}</pre>

{plot_html}

</body>
</html>"""

    with open(output_path, "w") as f:
        f.write(html)


def generate_map_report(result, records, output_path):
    """Generate a self-contained HTML report for a mapped guide library.

    Summarises how well an existing guide library maps to a new genome:
    match rate, on-target score distribution, off-target counts, and bad seeds.

    Parameters
    ----------
    result : DataFrame
        Output of :func:`crisprbact.map_library.map_library`.
    records : list of SeqRecord
        Parsed genome records for the reference genome.
    output_path : str or path-like
        Path to write the HTML report.
    """
    genome_name = records[0].description if records else "Unknown"
    total_genome_bp = sum(len(rec.seq) for rec in records)

    # Per-guide stats (deduplicate: one row per guide)
    guide_df = result.drop_duplicates("guide")
    n_total = len(guide_df)
    n_matched = int((guide_df["n_matches"] > 0).sum())
    n_unmatched = n_total - n_matched
    match_pct = n_matched / n_total * 100 if n_total > 0 else 0.0

    matched_df = result[result["n_matches"] > 0].drop_duplicates("guide")
    scores = matched_df["on_target_score"].dropna().values

    # Gene coverage: count unique guides per locus_tag, add zeros for uncovered genes
    all_locus_tags = {
        feat.qualifiers["locus_tag"][0]
        for rec in records
        for feat in rec.features
        if feat.type == "gene" and "locus_tag" in feat.qualifiers
    }
    gene_guide_counts = (
        result[result["locus_tag"].notna()]
        .groupby("locus_tag")["guide"]
        .nunique()
    )
    # Include all genes: covered ones get their count, uncovered ones get 0
    guides_per_gene = {lt: int(gene_guide_counts.get(lt, 0)) for lt in all_locus_tags}
    n_genes_total = len(all_locus_tags)
    n_genes_covered = sum(1 for c in guides_per_gene.values() if c > 0)
    gene_cov_pct = n_genes_covered / n_genes_total * 100 if n_genes_total > 0 else 0.0

    stats_lines = [
        "REFERENCE GENOME",
        f"  Records:      {len(records)}",
        f"  Genome size:  {total_genome_bp:,} bp",
        f"  Total genes:  {n_genes_total}",
        "",
        "LIBRARY SUMMARY",
        f"  Total guides in input:  {n_total}",
        f"  Guides with a target:   {n_matched} ({match_pct:.1f}%)",
        f"  Guides without target:  {n_unmatched}",
        "",
        "GENE COVERAGE",
        f"  Genes with ≥1 guide:  {n_genes_covered} / {n_genes_total} ({gene_cov_pct:.1f}%)",
        f"  Genes with 0 guides:  {n_genes_total - n_genes_covered}",
    ]

    if len(scores) > 0:
        _q1, _q2, _q3 = SCORE_BOUNDARIES
        pct_q1 = (scores >= _q1).sum() / len(scores) * 100
        pct_q2 = ((scores >= _q2) & (scores < _q1)).sum() / len(scores) * 100
        pct_q3 = ((scores >= _q3) & (scores < _q2)).sum() / len(scores) * 100
        pct_q4 = (scores < _q3).sum() / len(scores) * 100
        stats_lines += [
            "",
            "ON-TARGET SCORES (matched guides only)",
            f"  Min:    {scores.min():.4f}",
            f"  Max:    {scores.max():.4f}",
            f"  Mean:   {scores.mean():.4f}",
            f"  Median: {np.median(scores):.4f}",
            f"  Quartile boundaries (Q1 > {_q1:.4f} > Q2 > {_q2:.4f} > Q3 > {_q3:.4f} > Q4):",
            f"    Q1 (score ≥ {_q1:.4f}):            {pct_q1:.1f}%",
            f"    Q2 ({_q2:.4f} ≤ score < {_q1:.4f}):   {pct_q2:.1f}%",
            f"    Q3 ({_q3:.4f} ≤ score < {_q2:.4f}): {pct_q3:.1f}%",
            f"    Q4 (score < {_q3:.4f}):          {pct_q4:.1f}%",
        ]

    if len(matched_df) > 0:
        ntargets = matched_df["ntargets"].values
        noff12 = matched_df["noff_12"].values
        noff11 = matched_df["noff_11_gene"].values
        noff9 = matched_df["noff_9_prom"].values
        stats_lines += [
            "",
            "OFF-TARGETS (matched guides only)",
            f"  Unique guides (ntargets=1): {(ntargets == 1).sum()} "
            f"({(ntargets == 1).mean() * 100:.1f}%)",
            f"  Mean ntargets:  {ntargets.mean():.2f}",
            f"  Mean noff_12:   {noff12.mean():.2f}",
            f"  Mean noff_11g:  {noff11.mean():.2f}",
            f"  Mean noff_9p:   {noff9.mean():.2f}",
        ]
        bs_count = int(matched_df["inbadseeds"].sum())
        bs_pct = bs_count / len(matched_df) * 100
        stats_lines += [
            "",
            "BAD SEEDS (matched guides only)",
            f"  Flagged guides: {bs_count} ({bs_pct:.1f}%)",
        ]

    stats_text = "\n".join(stats_lines)

    # --- Wrong-orientation analysis ---
    wrong_ori = result[result["targets_coding_strand"] == False]
    n_wrong_guides = wrong_ori["guide"].nunique()
    n_wrong_genes = wrong_ori["locus_tag"].nunique()

    # Build per-gene summary: locus_tag, gene name, gene_ori, guide strand, guide count
    if len(wrong_ori) > 0:
        wrong_gene_summary = (
            wrong_ori.groupby(["locus_tag", "gene", "gene_ori", "strand"])
            .agg(n_guides=("guide", "nunique"))
            .reset_index()
            .sort_values("n_guides", ascending=False)
        )
        wrong_table_rows = ""
        for _, row in wrong_gene_summary.iterrows():
            gene_name = row["gene"] if str(row["gene"]) != "nan" else "—"
            ori_str = "+" if row["gene_ori"] == 1 else "−"
            wrong_table_rows += (
                f"<tr>"
                f"<td>{html_module.escape(str(row['locus_tag']))}</td>"
                f"<td>{html_module.escape(gene_name)}</td>"
                f"<td>{ori_str}</td>"
                f"<td>{html_module.escape(str(row['strand']))}</td>"
                f"<td>{int(row['n_guides'])}</td>"
                f"</tr>\n"
            )
        wrong_table_html = (
            "<table border='1' cellpadding='5' cellspacing='0' "
            "style='border-collapse:collapse; font-size:0.85em;'>\n"
            "<tr>"
            "<th>Locus tag</th><th>Gene</th>"
            "<th>Gene strand</th><th>Guide strand</th><th>Guides</th>"
            "</tr>\n"
            + wrong_table_rows
            + "</table>"
        )
    else:
        wrong_table_html = "<p>No wrong-orientation matches found.</p>"

    wrong_ori_html = f"""
<h2>Wrong-orientation targets ({n_wrong_guides} guides, {n_wrong_genes} genes)</h2>
<p>
These guides have a PAM site within an annotated gene in the reference genome, but on
the <strong>non-coding (template) strand</strong> rather than the coding strand.
CRISPRi works by blocking RNA polymerase on the coding strand; guides on the template
strand are not expected to provide strong silencing of the gene shown below.
</p>
<p>
<strong>Why does this happen?</strong> The source guide may target an antisense RNA
or a gene that overlaps with the listed reference gene on the opposite strand.
These guides are not expected to provide strong silencing.
</p>
{wrong_table_html}
"""

    # --- Plots ---
    plt = _require_matplotlib()

    # Score distribution (matched guides)
    fig_score, ax = plt.subplots(figsize=(8, 5))
    if len(scores) > 0:
        ax.hist(scores, bins=50, edgecolor="black", alpha=0.7)
        _q1, _q2, _q3 = SCORE_BOUNDARIES
        for val, label in [(_q1, "Q1/Q2"), (_q2, "Q2/Q3"), (_q3, "Q3/Q4")]:
            ax.axvline(val, color="red", linestyle="--", alpha=0.7)
            ax.text(val, ax.get_ylim()[1] * 0.95, f" {label}", fontsize=8, color="red")
    ax.set_xlabel("On-target score")
    ax.set_ylabel("Number of guides")
    ax.set_title("Score distribution (matched guides)")
    fig_score.tight_layout()

    # n_matches distribution
    fig_matches, ax2 = plt.subplots(figsize=(8, 5))
    n_matches_vals = guide_df["n_matches"].values
    vals, cnts = np.unique(n_matches_vals, return_counts=True)
    ax2.bar(vals, cnts, width=0.8, edgecolor="black", alpha=0.7)
    ax2.set_xlabel("Number of PAM-site matches (n_matches)")
    ax2.set_ylabel("Number of guides")
    ax2.set_title("PAM-site match count distribution")
    fig_matches.tight_layout()

    # Gene coverage distribution
    fig_gene_cov, ax3 = plt.subplots(figsize=(8, 5))
    counts_arr = np.array(list(guides_per_gene.values()))
    vals3, cnts3 = np.unique(counts_arr, return_counts=True)
    ax3.bar(vals3, cnts3, width=0.8, edgecolor="black", alpha=0.7)
    ax3.set_xlabel("Number of guides targeting the gene")
    ax3.set_ylabel("Number of genes")
    ax3.set_title(
        f"Gene coverage  ({n_genes_covered}/{n_genes_total} genes with ≥1 guide)"
    )
    fig_gene_cov.tight_layout()

    # Off-target distribution (matched guides)
    fig_off, axes = plt.subplots(1, 2, figsize=(12, 5))
    if len(matched_df) > 0:
        axes[0].hist(matched_df["ntargets"].values, bins=50, edgecolor="black", alpha=0.7)
        axes[0].set_xlabel("ntargets")
        axes[0].set_ylabel("Number of guides")
        axes[0].set_title("ntargets distribution")
        axes[1].hist(matched_df["noff_12"].values, bins=50, edgecolor="black", alpha=0.7)
        axes[1].set_xlabel("noff_12")
        axes[1].set_ylabel("Number of guides")
        axes[1].set_title("noff_12 distribution")
    fig_off.tight_layout()

    score_b64 = _fig_to_base64(fig_score)
    matches_b64 = _fig_to_base64(fig_matches)
    gene_cov_b64 = _fig_to_base64(fig_gene_cov)
    off_b64 = _fig_to_base64(fig_off)

    plot_html = (
        _plot_row([("Gene coverage", gene_cov_b64),
                   ("Targets per guide", matches_b64)])
        + _plot_row([("Score distribution (guides with a target)", score_b64)])
        + _plot_row([("Off-target distribution (guides with a target)", off_b64)])
    )

    html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>CRISPRi Library Map Report</title>
<style>
{_HTML_BASE_CSS}
</style>
</head>
<body>
<h1>CRISPRi Library Map Report</h1>
<p><strong>Reference genome:</strong> {html_module.escape(genome_name)}</p>

<h2>Statistics</h2>
<pre>{html_module.escape(stats_text)}</pre>

{plot_html}

{wrong_ori_html}

</body>
</html>"""

    with open(output_path, "w") as f:
        f.write(html)


def _coverage_table_html(existing_coverage, addon_coverage, all_gene_lts, n):
    """Return an HTML table comparing existing / add-on / combined coverage.

    Parameters
    ----------
    existing_coverage : dict
        {locus_tag: n_guides} for coding-strand guides in the existing library.
    addon_coverage : dict
        {locus_tag: n_guides} for guides in the add-on library.
    all_gene_lts : iterable
        All locus tags in the target genome.
    n : int
        Full-coverage threshold (guides per gene).
    """
    all_lts = list(all_gene_lts)
    total = len(all_lts)

    def _count(cov, threshold):
        return sum(1 for lt in all_lts if cov.get(lt, 0) >= threshold)

    combined = {lt: existing_coverage.get(lt, 0) + addon_coverage.get(lt, 0) for lt in all_lts}

    def _pct(k):
        return f"{k}&thinsp;/&thinsp;{total}&ensp;({100 * k / total:.1f}%)" if total else str(k)

    ex_guides = sum(existing_coverage.get(lt, 0) for lt in all_lts)
    ao_guides = sum(addon_coverage.get(lt, 0) for lt in all_lts)

    rows = [
        ("Total coding-strand guide–gene pairs", str(ex_guides), str(ao_guides), str(ex_guides + ao_guides)),
        ("Genes with ≥1 guide", _pct(_count(existing_coverage, 1)), _pct(_count(addon_coverage, 1)), _pct(_count(combined, 1))),
        (f"Genes fully covered (≥{n} guides)", _pct(_count(existing_coverage, n)), _pct(_count(addon_coverage, n)), _pct(_count(combined, n))),
    ]

    body = ""
    for i, (metric, ex, ao, co) in enumerate(rows):
        bg = " class=\"alt\"" if i % 2 else ""
        body += (
            f"<tr{bg}>"
            f"<td class=\"metric\">{metric}</td>"
            f"<td>{ex}</td>"
            f"<td>{ao}</td>"
            f"<td class=\"combined\">{co}</td>"
            f"</tr>"
        )

    return (
        "<table class=\"cov\">"
        "<thead><tr>"
        "<th>Metric</th><th>Existing library</th><th>Add-on library</th>"
        "<th class=\"combined\">Combined</th>"
        "</tr></thead>"
        f"<tbody>{body}</tbody>"
        "</table>"
    )


def generate_addon_report(
    addon_lib, map_result, records, existing_coverage, n, output_path
):
    """Generate a self-contained HTML report for a CRISPRi add-on library.

    Shows coverage statistics before and after mixing the add-on library
    with the existing library.

    Parameters
    ----------
    addon_lib : DataFrame
        Output of generate_addon_library(). May be empty.
    map_result : DataFrame
        Output of map_library() for the existing library mapped to the target genome.
    records : list of SeqRecord
        Parsed genome records for the target genome.
    existing_coverage : dict
        {locus_tag: n_guides} for coding-strand guides in the existing library.
    n : int
        Target total guides per gene (used as the coverage threshold).
    output_path : str or path-like
        Path to write the HTML report.
    """
    import html as html_module

    genome_name = records[0].description if records else "Unknown"

    # All genes in genome
    all_gene_info = _all_gene_info(records)
    total_genes = len(all_gene_info)

    # Per-gene guide counts for the add-on library
    addon_coverage = {}
    if len(addon_lib) > 0 and "locus_tag" in addon_lib.columns:
        for lt, grp in addon_lib.groupby("locus_tag"):
            addon_coverage[lt] = len(grp)

    # Coverage sets (threshold = n)
    needs_addon_set = {lt for lt in all_gene_info if existing_coverage.get(lt, 0) < n}
    addon_covered = set(addon_coverage.keys())
    still_uncovered = needs_addon_set - addon_covered

    n_still_uncovered = len(still_uncovered)
    n_addon_guides = len(addon_lib)

    # Coverage comparison table
    cov_table = _coverage_table_html(existing_coverage, addon_coverage, all_gene_info.keys(), n)

    # Score stats for add-on guides
    score_stats_html = ""
    if n_addon_guides > 0:
        scores = addon_lib["score"].values
        _q1, _q2, _q3 = SCORE_BOUNDARIES
        pct_q1 = (scores >= _q1).sum() / len(scores) * 100
        pct_q2 = ((scores >= _q2) & (scores < _q1)).sum() / len(scores) * 100
        pct_q3 = ((scores >= _q3) & (scores < _q2)).sum() / len(scores) * 100
        pct_q4 = (scores < _q3).sum() / len(scores) * 100
        score_rows = [
            ("Total add-on guides", str(n_addon_guides)),
            ("Target guides per gene (n)", str(n)),
            ("Min score", f"{scores.min():.4f}"),
            ("Max score", f"{scores.max():.4f}"),
            ("Mean score", f"{scores.mean():.4f}"),
            ("Median score", f"{np.median(scores):.4f}"),
            (f"Q1 (score ≥ {_q1:.4f})", f"{pct_q1:.1f}%"),
            (f"Q2 ({_q2:.4f} ≤ score < {_q1:.4f})", f"{pct_q2:.1f}%"),
            (f"Q3 ({_q3:.4f} ≤ score < {_q2:.4f})", f"{pct_q3:.1f}%"),
            (f"Q4 (score < {_q3:.4f})", f"{pct_q4:.1f}%"),
        ]
        alt_cls = ' class="alt"'
        score_body = "".join(
            f"<tr{alt_cls if i % 2 else ''}><td class=\"metric\">{k}</td><td>{v}</td></tr>"
            for i, (k, v) in enumerate(score_rows)
        )
        score_stats_html = (
            "<h2>Add-on Library Statistics</h2>"
            "<table class=\"cov\" style=\"max-width:500px;\">"
            f"<tbody>{score_body}</tbody>"
            "</table>"
        )

    # Still-uncovered genes
    if still_uncovered:
        uncov_lines = [f"GENES STILL UNCOVERED ({n_still_uncovered}):"]
        for lt in sorted(still_uncovered):
            info = all_gene_info.get(lt)
            if info:
                name, size, func = info
                line = f"  {lt}  ({name}): {size} bp"
                if func:
                    line += f" — {func}"
            else:
                line = f"  {lt}"
            uncov_lines.append(line)
        uncovered_html = f'<pre class="warn">{html_module.escape(chr(10).join(uncov_lines))}</pre>'
    else:
        uncovered_html = '<p style="color:#2e7d32;">&#10003; All genes are covered after combining libraries.</p>'

    # Plots
    plot_html = ""
    if n_addon_guides > 0:
        score_b64 = _fig_to_base64(plot_score_distribution(addon_lib))
        gpg_b64 = _fig_to_base64(plot_guides_per_gene(addon_lib))
        plot_html = _plot_row(
            [
                ("Score distribution (add-on guides)", score_b64),
                ("Guides per gene (add-on)", gpg_b64),
            ]
        )

    html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>CRISPRi Add-on Library Report</title>
<style>
{_HTML_BASE_CSS}
  .warn {{ background: #fff8e1; border-left: 4px solid #f9a825; }}
  table.cov {{ border-collapse: collapse; margin-bottom: 20px; }}
  table.cov th, table.cov td {{ padding: 8px 16px; border: 1px solid #ddd; text-align: left; }}
  table.cov thead tr {{ background: #4a90d9; color: #fff; }}
  table.cov th.combined {{ background: #2e7d32; }}
  table.cov tr.alt td {{ background: #f5f8ff; }}
  table.cov td.metric {{ font-weight: 500; }}
  table.cov td.combined {{ background: #e8f5e9; font-weight: bold; }}
</style>
</head>
<body>
<h1>CRISPRi Add-on Library Report</h1>
<p><strong>Genome:</strong> {html_module.escape(genome_name)}</p>

<h2>Coverage Summary</h2>
{cov_table}

<h2>Still-uncovered Genes</h2>
{uncovered_html}

{score_stats_html}

{plot_html}
</body>
</html>"""

    with open(output_path, "w") as f:
        f.write(html)


def inspect_gene_summary(library, query):
    """Compact table view of guides targeting a gene.

    Parameters
    ----------
    library : DataFrame
        Output of generate_library().
    query : str
        Gene name or locus_tag.

    Returns
    -------
    str
        Formatted table of guide details.
    """
    gene_guides = library[
        (library["locus_tag"] == query) | (library["gene"] == query)
    ].sort_values("gene_rank")

    if len(gene_guides) == 0:
        return f"No guides found for '{query}'."

    lines = []
    # Header
    cols = [
        ("rank", 5), ("guide", 22), ("strand", 7), ("pos", 10),
        ("score", 8), ("quartile", 9), ("ntargets", 9),
        ("noff_12", 8), ("noff_11g", 9), ("inbadseeds", 11),
        ("2nd_half", 9),
    ]
    header = "".join(f"{name:<{width}}" for name, width in cols)
    lines.append(header)
    lines.append("-" * len(header))

    for _, row in gene_guides.iterrows():
        vals = [
            (str(int(row["gene_rank"])), 5),
            (row["guide"], 22),
            (row["strand"], 7),
            (str(int(row["pos"])), 10),
            (f"{row['score']:.3f}", 8),
            (str(int(row.get("score_quartile", 0))), 9),
            (str(int(row.get("ntargets", 0))), 9),
            (str(int(row.get("noff_12", 0))), 8),
            (str(int(row.get("noff_11_gene", 0))), 9),
            (str(row.get("inbadseeds", "")), 11),
            (str(row.get("second_half_gene", "")), 9),
        ]
        lines.append("".join(f"{v:<{w}}" for v, w in vals))

    return "\n".join(lines)
