# vendored from docs/report_template/style.py -- do not edit; regenerate by re-copying
"""
docs/report_template/style.py

Shared STRUCTURAL primitives for AI Bioinformatician PDF reports. Import these
from a task's build-PDF script instead of re-implementing the navy/white
consultancy chrome each time. This is the single source of truth for:

  - palette (hex colours)               PALETTE
  - paragraph styles                    STYLES
  - page / document setup               make_doc()      (banner + footer on EVERY page)
  - the page-1 notice box               notice_box()
  - inline status callouts              callout()
  - data tables                         make_table()
  - PASS / WARN / FAIL cell text        status_para()
  - numbered references list            references_block()
  - aspect-safe image embedding         embed_image()
  - section / subsection headings       section(), subsection()

Figures are deliberately OUT OF SCOPE here -- figure conventions live in
AGENTS.md and are composed per task. This module only covers the report
chrome that is identical across tasks.

Geometry note: make_doc() fixes the banner-to-content gap at exactly TOP_GAP
(14 pt) on every page, per the AGENTS.md CRITICAL rule. Do not hand-roll the
document template; that is exactly where past reports drifted.

Usage: this file is VENDORED into each task -- copy it verbatim (do not edit) to
<project>/scripts/report_style.py, then import it as a sibling in
scripts/06_build_pdf.py (Python puts the script's own dir on sys.path, so no path
hack is needed):

    from report_style import (make_doc, notice_box, callout, make_table,
                              status_para, references_block, bullet, section,
                              subsection, embed_image, PALETTE, STYLES, CONTENT_W)

    doc = make_doc("output/Target_Assay_Report.pdf",
                   title="Target Assay Design Report",
                   subtitle="SYNTH1 -- ddPCR detection",
                   meta="28 May 2026  |  rev 1",
                   footer_left="SYNTH1 ddPCR", footer_center="28 May 2026")
    story = [notice_box("pass", "All constraints met", "..."), ...]
    doc.build(story)

See README.md and Reference_Report.pdf in this folder for a worked example.
"""

from reportlab.lib import colors
from reportlab.lib.colors import HexColor
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import (
    BaseDocTemplate, Frame, Image, PageTemplate, Paragraph, Table, TableStyle,
)

# ----------------------------------------------------------------------------
# Palette  (AGENTS.md PDF Report Style Guide -- single source of truth)
# ----------------------------------------------------------------------------
PALETTE = {
    "navy":        HexColor("#1B2A4A"),   # banner, section titles, table header rows
    "white":       colors.white,
    "dark_gray":   HexColor("#1A1A1A"),   # body text
    "light_gray":  HexColor("#F5F5F5"),   # alternating table rows
    "mid_gray":    HexColor("#CCCCCC"),   # grid lines, divider rules
    "caption":     HexColor("#666666"),   # captions, footnotes
    "green":       HexColor("#059669"),   # PASS
    "amber":       HexColor("#D97706"),   # WARN
    "red":         HexColor("#DC2626"),   # FAIL
    "light_green": HexColor("#ECFDF5"),   # all-pass notice tint
    "light_amber": HexColor("#FFFBEB"),   # warning notice tint
    "light_red":   HexColor("#FEF2F2"),   # failure notice tint
}

# ----------------------------------------------------------------------------
# Page geometry  (A4, 18 mm side margins, 52 pt banner, 14 pt banner-to-content)
# ----------------------------------------------------------------------------
PAGE_W, PAGE_H = A4
MARGIN_L = 18 * mm
MARGIN_R = 18 * mm
MARGIN_B = 16 * mm
BANNER_H = 52          # pt -- per AGENTS.md (gives breathing room vs 38)
TOP_GAP = 14           # pt -- CRITICAL: exact gap banner bottom -> first element
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R

_FRAME_TOP = PAGE_H - BANNER_H - TOP_GAP
_FRAME_BOTTOM = MARGIN_B + 14          # leave room for footer rule + text
_FOOTER_RULE_Y = MARGIN_B + 12
_FOOTER_TEXT_Y = MARGIN_B + 4

# ----------------------------------------------------------------------------
# Paragraph styles
# ----------------------------------------------------------------------------
STYLES = {
    "body": ParagraphStyle("body", fontName="Helvetica", fontSize=9, leading=13,
                           textColor=PALETTE["dark_gray"], spaceAfter=5),
    "bold": ParagraphStyle("bold", fontName="Helvetica-Bold", fontSize=9, leading=13,
                           textColor=PALETTE["dark_gray"], spaceAfter=5),
    # keepWithNext: a heading is never the last flowable on a page -- ReportLab
    # pushes it to the next page if its following content would not fit. Fixes
    # orphaned headings centrally, so build scripts need no manual KeepTogether.
    "section": ParagraphStyle("section", fontName="Helvetica-Bold", fontSize=13,
                              textColor=PALETTE["navy"], spaceBefore=10, spaceAfter=4,
                              keepWithNext=True),
    "subsection": ParagraphStyle("subsection", fontName="Helvetica-Bold", fontSize=10,
                                 textColor=PALETTE["navy"], spaceBefore=8, spaceAfter=3,
                                 keepWithNext=True),
    "cell": ParagraphStyle("cell", fontName="Helvetica", fontSize=8.5, leading=11,
                           textColor=PALETTE["dark_gray"]),
    "cell_mono": ParagraphStyle("cell_mono", fontName="Courier", fontSize=7.5, leading=10,
                                textColor=PALETTE["dark_gray"]),
    "header_cell": ParagraphStyle("header_cell", fontName="Helvetica-Bold", fontSize=8.5,
                                  leading=11, textColor=PALETTE["white"]),
    "caption": ParagraphStyle("caption", fontName="Helvetica-Oblique", fontSize=8,
                              textColor=PALETTE["caption"], alignment=TA_CENTER,
                              spaceBefore=4, spaceAfter=8),
    "ref": ParagraphStyle("ref", fontName="Helvetica", fontSize=8.5, leading=11.5,
                          textColor=PALETTE["dark_gray"], spaceAfter=3,
                          leftIndent=22, firstLineIndent=-22),
    "bullet": ParagraphStyle("bullet", fontName="Helvetica", fontSize=9, leading=13,
                             textColor=PALETTE["dark_gray"], spaceAfter=3,
                             leftIndent=14, bulletIndent=4),
    "pass": ParagraphStyle("pass", fontName="Helvetica-Bold", fontSize=8.5, leading=11,
                           textColor=PALETTE["green"]),
    "warn": ParagraphStyle("warn", fontName="Helvetica-Bold", fontSize=8.5, leading=11,
                           textColor=PALETTE["amber"]),
    "fail": ParagraphStyle("fail", fontName="Helvetica-Bold", fontSize=8.5, leading=11,
                           textColor=PALETTE["red"]),
}

# ----------------------------------------------------------------------------
# Document setup -- banner + footer on every page, 14 pt gap fixed centrally
# ----------------------------------------------------------------------------
def make_doc(out_path, *, title, subtitle, meta, footer_left, footer_center):
    """Return a BaseDocTemplate with the navy banner + footer drawn on every
    page. Caller appends flowables to a story list and calls doc.build(story).

    title       : banner line 1 (bold 16 pt)
    subtitle    : banner line 2 (11 pt) -- gene/variant/target + transcript
    meta        : banner line 3 provenance, shown after the fixed
                  "AI Bioinformatician by Labterna" attribution prefix -- e.g.
                  "28 May 2026 | GRCh38 | rev 2" or "2026-05-25 | mode=ncbi".
    footer_left : short gene/variant identifier shown bottom-left every page
    footer_center : date string shown bottom-centre every page
    """
    def _draw(canv, doc):
        canv.saveState()
        # banner (full bleed to page edges)
        canv.setFillColor(PALETTE["navy"])
        canv.rect(0, PAGE_H - BANNER_H, PAGE_W, BANNER_H, fill=1, stroke=0)
        canv.setFillColor(PALETTE["white"])
        canv.setFont("Helvetica-Bold", 16)
        canv.drawCentredString(PAGE_W / 2, PAGE_H - 19, title)
        canv.setFont("Helvetica", 11)
        canv.drawCentredString(PAGE_W / 2, PAGE_H - 33, subtitle)
        canv.setFont("Helvetica", 9)
        canv.drawCentredString(PAGE_W / 2, PAGE_H - 45,
                               f"AI Bioinformatician by Labterna  |  {meta}")
        # footer rule + text
        canv.setStrokeColor(PALETTE["mid_gray"])
        canv.setLineWidth(0.5)
        canv.line(MARGIN_L, _FOOTER_RULE_Y, PAGE_W - MARGIN_R, _FOOTER_RULE_Y)
        canv.setFillColor(PALETTE["caption"])
        canv.setFont("Helvetica", 7.5)
        canv.drawString(MARGIN_L, _FOOTER_TEXT_Y, footer_left)
        canv.drawCentredString(PAGE_W / 2, _FOOTER_TEXT_Y, footer_center)
        canv.drawRightString(PAGE_W - MARGIN_R, _FOOTER_TEXT_Y, f"Page {doc.page}")
        canv.restoreState()

    doc = BaseDocTemplate(
        out_path, pagesize=A4,
        leftMargin=MARGIN_L, rightMargin=MARGIN_R,
        topMargin=BANNER_H + TOP_GAP, bottomMargin=_FRAME_BOTTOM,
        title=title, author="AI Bioinformatician by Labterna",
    )
    # Frame top sits exactly TOP_GAP below the banner; padding 0 so the first
    # flowable is not pushed down further. ReportLab suppresses the first
    # flowable's spaceBefore at the top of a frame, so the gap is constant on
    # every page.
    frame = Frame(MARGIN_L, _FRAME_BOTTOM, CONTENT_W, _FRAME_TOP - _FRAME_BOTTOM,
                  leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0,
                  id="main")
    doc.addPageTemplates([PageTemplate(id="main", frames=[frame], onPage=_draw)])
    return doc


# ----------------------------------------------------------------------------
# Notice box (page 1) and inline callouts
# ----------------------------------------------------------------------------
_NOTICE = {
    "pass": ("+", "green", "light_green"),
    "warn": ("!", "amber", "light_amber"),
    "fail": ("x", "red", "light_red"),
}


def notice_box(level, headline, detail):
    """Page-1 summary box. level in {'pass','warn','fail'}. Icon on the left,
    bold coloured headline + body detail on the right. Max two lines of detail
    -- this is a visual anchor, not a section."""
    icon_char, border_key, bg_key = _NOTICE[level]
    border, bg = PALETTE[border_key], PALETTE[bg_key]
    icon = Paragraph(
        f"<b>{icon_char}</b>",
        ParagraphStyle("nbi", fontName="Helvetica-Bold", fontSize=18, leading=18,
                       alignment=TA_CENTER, textColor=border))
    head = Paragraph(
        f"<b>{headline}</b>",
        ParagraphStyle("nbh", fontName="Helvetica-Bold", fontSize=10, leading=12,
                       textColor=border))
    body = Paragraph(
        detail,
        ParagraphStyle("nbd", fontName="Helvetica", fontSize=9, leading=12,
                       textColor=PALETTE["dark_gray"]))
    # Icon centered in a 46 pt left zone: zero horizontal padding so the
    # TA_CENTER glyph centres across the whole column (balanced space on both
    # sides). Width must exceed padding or ReportLab raises negative-availWidth.
    t = Table([[icon, [head, body]]], colWidths=[46, CONTENT_W - 46])
    t.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, -1), bg),
        ("BOX", (0, 0), (-1, -1), 1.0, border),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("LEFTPADDING", (0, 0), (0, 0), 0),
        ("RIGHTPADDING", (0, 0), (0, 0), 0),
        ("LEFTPADDING", (1, 0), (1, 0), 6),
        ("RIGHTPADDING", (1, 0), (1, 0), 8),
        ("TOPPADDING", (0, 0), (-1, -1), 10),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
    ]))
    return t


def callout(level, text):
    """Inline coloured callout used mid-report (e.g. a 3'-end warning). Same
    colour semantics as notice_box but a single flowable with no icon column."""
    _, border_key, bg_key = _NOTICE[level]
    border, bg = PALETTE[border_key], PALETTE[bg_key]
    para = Paragraph(text, ParagraphStyle("co", fontName="Helvetica", fontSize=8.5,
                                          leading=11.5, textColor=PALETTE["dark_gray"]))
    t = Table([[para]], colWidths=[CONTENT_W])
    t.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, -1), bg),
        ("BOX", (0, 0), (-1, -1), 1.0, border),
        ("TOPPADDING", (0, 0), (-1, -1), 6),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
        ("LEFTPADDING", (0, 0), (-1, -1), 10),
        ("RIGHTPADDING", (0, 0), (-1, -1), 8),
    ]))
    t.spaceBefore = 6   # consistent gap above every callout, regardless of caller
    t.spaceAfter = 2
    return t


# ----------------------------------------------------------------------------
# Tables
# ----------------------------------------------------------------------------
def make_table(headers, rows, col_widths=None):
    """Styled table with navy header row, alternating row shading, grid lines.

    Cells are wrapped in Paragraph automatically (so text wraps within the
    column instead of bleeding) UNLESS a cell is already a flowable
    (Paragraph/Image), in which case it is passed through -- this lets callers
    pass status_para() or monospace sequence cells.

    col_widths : list summing to CONTENT_W. If None, columns are equal width.
    """
    data = [[Paragraph(str(h), STYLES["header_cell"]) for h in headers]]
    for row in rows:
        data.append([c if hasattr(c, "wrap")
                     else Paragraph(str(c), STYLES["cell"]) for c in row])
    if col_widths is None:
        n = len(headers)
        col_widths = [CONTENT_W / n] * n
    t = Table(data, colWidths=col_widths, repeatRows=1)
    cmds = [
        ("BACKGROUND", (0, 0), (-1, 0), PALETTE["navy"]),
        ("TEXTCOLOR", (0, 0), (-1, 0), PALETTE["white"]),
        ("GRID", (0, 0), (-1, -1), 0.5, PALETTE["mid_gray"]),
        ("TOPPADDING", (0, 0), (-1, -1), 4),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
        ("LEFTPADDING", (0, 0), (-1, -1), 6),
        ("RIGHTPADDING", (0, 0), (-1, -1), 6),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ]
    for i in range(2, len(data), 2):
        cmds.append(("BACKGROUND", (0, i), (-1, i), PALETTE["light_gray"]))
    t.setStyle(TableStyle(cmds))
    return t


def status_para(status):
    """Return a coloured PASS/WARN/FAIL Paragraph for a status table cell.
    Anything other than PASS/WARN/FAIL is rendered in the WARN colour."""
    key = {"PASS": "pass", "WARN": "warn", "FAIL": "fail"}.get(status, "warn")
    return Paragraph(status, STYLES[key])


# ----------------------------------------------------------------------------
# Headings and references
# ----------------------------------------------------------------------------
def section(title):
    """Numbered section heading, e.g. section('1. Target Biology')."""
    return Paragraph(title, STYLES["section"])


def subsection(title):
    """Numbered subsection heading, e.g. subsection('1.1 Gene Structure')."""
    return Paragraph(title, STYLES["subsection"])


def bullet(text):
    """A single bullet list item (filled round bullet, hanging indent). Reports
    use real bullets, not hyphens, for list items (e.g. a 'Preserved from rev N'
    list)."""
    return Paragraph(f"<bullet>&bull;</bullet>{text}", STYLES["bullet"])


def references_block(refs):
    """Return a list of hanging-indent reference Paragraphs numbered with bold
    bracketed [N] markers (the house citation style). `refs` is a list of
    fully-formatted citation strings (author, year, journal/DOI/PMID); put the
    journal name in <i>...</i>. The caller adds the section('N. References')
    heading."""
    return [Paragraph(f"<b>[{i}]</b> {r}", STYLES["ref"]) for i, r in enumerate(refs, 1)]


# ----------------------------------------------------------------------------
# Image embedding (aspect-safe)  -- structural helper, NOT a figure generator
# ----------------------------------------------------------------------------
def embed_image(png_path, max_width=None):
    """Embed a PNG preserving aspect ratio. Width defaults to full content
    width. Derives height from the measured pixel aspect ratio so the image is
    never stretched (the AGENTS.md figure rule). Returns a Paragraph error
    marker if the file is missing rather than raising."""
    import os
    from PIL import Image as PILImage
    if not os.path.exists(png_path):
        return Paragraph(f"[Figure not found: {png_path}]", STYLES["body"])
    w_px, h_px = PILImage.open(png_path).size
    width = max_width or CONTENT_W
    height = width * (h_px / w_px)
    return Image(png_path, width=width, height=height)
