#!/usr/bin/env python3
"""
Sheet Music Accidental Analyzer — Highlighter Edition
Markiert Vorzeichen (Schlüssel + Melodie-Noten) mit Leuchtstift-Effekt.
"""

import sys, argparse
import numpy as np
import cv2
import pymupdf
from pathlib import Path

# ─── Templates ───────────────────────────────────────────────────────────────

def load_templates():
    base = Path(__file__).parent
    fp = base / "tpl_flat.png"
    sp = base / "tpl_sharp.png"
    if not fp.exists() or not sp.exists():
        raise FileNotFoundError(f"tpl_flat.png / tpl_sharp.png nicht gefunden neben {__file__}")
    flat  = cv2.imread(str(fp),  cv2.IMREAD_GRAYSCALE)
    sharp = cv2.imread(str(sp), cv2.IMREAD_GRAYSCALE)
    if flat is None or sharp is None:
        raise ValueError("Template-PNGs konnten nicht geladen werden")
    return flat, sharp

# ─── Tonart ──────────────────────────────────────────────────────────────────

SHARP_KEYS = {
    0: ("C-Dur / a-Moll",    "#f0f0f0"), 1: ("G-Dur / e-Moll",    "#ffe566"),
    2: ("D-Dur / h-Moll",    "#ffc044"), 3: ("A-Dur / fis-Moll",  "#ff9933"),
    4: ("E-Dur / cis-Moll",  "#ff6622"), 5: ("H-Dur / gis-Moll",  "#ee3311"),
    6: ("Fis-Dur / dis-Moll","#cc1188"), 7: ("Cis-Dur / ais-Moll","#881166"),
}
FLAT_KEYS = {
    0: ("C-Dur / a-Moll",    "#f0f0f0"), 1: ("F-Dur / d-Moll",    "#aaddff"),
    2: ("B-Dur / g-Moll",    "#66bbff"), 3: ("Es-Dur / c-Moll",   "#3399ff"),
    4: ("As-Dur / f-Moll",   "#1166dd"), 5: ("Des-Dur / b-Moll",  "#0044aa"),
    6: ("Ges-Dur / es-Moll", "#002277"), 7: ("Ces-Dur / as-Moll", "#001144"),
}

def estimate_key(n_sharps, n_flats):
    if n_sharps == 0 and n_flats == 0: return SHARP_KEYS[0]
    if n_sharps >= n_flats:            return SHARP_KEYS.get(min(n_sharps,7), SHARP_KEYS[7])
    return FLAT_KEYS.get(min(n_flats,7), FLAT_KEYS[7])

def hex_to_bgr(h):
    h = h.lstrip("#")
    return (int(h[4:6],16), int(h[2:4],16), int(h[0:2],16))

def draw_key_box(img, key_label, key_hex, n_sharps, n_flats):
    ih, iw = img.shape[:2]
    bw, bh = 310, 88
    x1, y1 = iw-bw-14, 14
    x2, y2 = x1+bw, y1+bh
    bg = hex_to_bgr(key_hex)
    overlay = img.copy()
    cv2.rectangle(overlay, (x1,y1), (x2,y2), bg, -1)
    cv2.addWeighted(overlay, 0.82, img, 0.18, 0, img)
    cv2.rectangle(img, (x1,y1), (x2,y2), (50,50,50), 2)
    lum = 0.299*int(key_hex[1:3],16)+0.587*int(key_hex[3:5],16)+0.114*int(key_hex[5:7],16)
    tc = (20,20,20) if lum > 140 else (240,240,240)
    cv2.putText(img, key_label, (x1+10,y1+32), cv2.FONT_HERSHEY_SIMPLEX, 0.72, tc, 2, cv2.LINE_AA)
    cv2.putText(img, f"Kreuze: {n_sharps}   Bs: {n_flats}", (x1+10,y1+64),
                cv2.FONT_HERSHEY_SIMPLEX, 0.60, tc, 1, cv2.LINE_AA)

# ─── Vorzeichen-Suche ────────────────────────────────────────────────────────

def find_symbol(gray, tpl, threshold, overlap=0.55):
    res = cv2.matchTemplate(gray, tpl, cv2.TM_CCOEFF_NORMED)
    th, tw = tpl.shape
    hits = list(zip(*np.where(res >= threshold)[::-1]))
    hits = sorted(hits, key=lambda p: res[p[1],p[0]], reverse=True)
    kept = []
    for p in hits:
        if not any(abs(p[0]-k[0]) < tw*overlap and abs(p[1]-k[1]) < th*overlap for k in kept):
            kept.append(p)
    return [(p[0], p[1], tw, th, float(res[p[1],p[0]])) for p in kept]

# ─── Notenköpfe finden ───────────────────────────────────────────────────────

def find_noteheads(gray):
    """Findet alle Notenköpfe im Bild via Connected Components."""
    _, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV)
    # Notenlinien entfernen
    kh = cv2.getStructuringElement(cv2.MORPH_RECT, (120, 1))
    horiz = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kh)
    clean = cv2.subtract(thresh, horiz)
    num, _, stats, _ = cv2.connectedComponentsWithStats(clean)
    heads = []
    for i in range(1, num):
        x, y, w, h, area = stats[i]
        ar = h / max(w, 1)
        # Notenkopf: rundlich, mittlere Grösse
        if 0.45 <= ar <= 1.5 and 10 <= w <= 45 and 7 <= h <= 38 and 60 <= area <= 900:
            heads.append((int(x), int(y), int(w), int(h)))
    return heads

def find_note_for_accidental(ax, ay, aw, ah, noteheads, max_dx=130, max_dy=45):
    """Findet den Notenkopf direkt rechts vom Vorzeichen."""
    acc_cx = ax + aw
    acc_cy = ay + ah // 2
    candidates = []
    for (nx, ny, nw, nh) in noteheads:
        dx = nx - acc_cx
        dy = abs((ny + nh//2) - acc_cy)
        if 0 <= dx <= max_dx and dy <= max_dy:
            candidates.append((dx, nx, ny, nw, nh))
    if not candidates:
        return None
    return min(candidates, key=lambda c: c[0])[1:]  # nächste Note

def is_real_flat(gray, x, y, w, h, look=60, thr_key=0.28, thr_mel=0.32):
    """
    Prüft ob ein Template-Treffer wirklich ein b ist.

    Schlüssel-bs (x < 250): haben Stiel links, sichtbar OBERHALB des Templates.
    Melodie-bs  (x >= 250): Stiel kann an jeder x-Position sein und nach
                            OBEN oder UNTEN zeigen → scanne alle Spalten.
    """
    if x < 250:
        # Schlüssel-b: Stiel ist links und geht nach oben
        aw    = min(w, 18)
        above = gray[max(0, y - look):y, max(0, x):max(0, x) + aw]
        if above.shape[0] < 5:
            return True
        _, bw  = cv2.threshold(above, 150, 255, cv2.THRESH_BINARY_INV)
        kh     = cv2.getStructuringElement(cv2.MORPH_RECT, (above.shape[1], 1))
        horiz  = cv2.morphologyEx(bw, cv2.MORPH_OPEN, kh)
        clean  = cv2.subtract(bw, horiz)
        cov    = np.sum(np.sum(clean[:, :6], axis=1) > 0) / above.shape[0]
        return cov > thr_key
    else:
        # Melodie-b: scanne alle x-Spalten des Template-Fensters, oben + unten
        max_cov = 0.0
        ih, iw  = gray.shape
        for dx in range(0, w, 2):
            col_x = x + dx
            if col_x < 0 or col_x + 3 >= iw:
                continue
            for y1, y2 in [(max(0, y - look), y),
                           (y + h, min(ih, y + h + look))]:
                strip = gray[y1:y2, col_x:col_x + 3]
                if strip.shape[0] < 5:
                    continue
                _, bw  = cv2.threshold(strip, 150, 255, cv2.THRESH_BINARY_INV)
                kh     = cv2.getStructuringElement(cv2.MORPH_RECT, (strip.shape[1], 1))
                horiz  = cv2.morphologyEx(bw, cv2.MORPH_OPEN, kh)
                clean  = cv2.subtract(bw, horiz)
                cov    = np.sum(np.sum(clean, axis=1) > 0) / strip.shape[0]
                if cov > max_cov:
                    max_cov = cov
        return max_cov > thr_mel


# ─── Notensystem-Erkennung ───────────────────────────────────────────────────

def detect_staff_bands(gray):
    row_dark = np.sum(gray < 128, axis=1)
    heavy = np.where(row_dark > row_dark.max() * 0.4)[0]
    if len(heavy) == 0: return []
    clusters, cur = [], [heavy[0]]
    for r in heavy[1:]:
        if r - cur[-1] <= 30: cur.append(r)
        else: clusters.append(cur); cur = [r]
    clusters.append(cur)
    return [(int(c[0]), int(c[-1])) for c in clusters if 40 < c[-1]-c[0] < 300]

def find_staff(cy, bands):
    return min(bands, key=lambda b: abs((b[0]+b[1])//2 - cy))

# ─── Leuchtstift-Effekt ──────────────────────────────────────────────────────

def _pill_mask(sw, sh, corner_r):
    """Abgerundetes Rechteck als Maske."""
    mask = np.zeros((sh, sw), dtype=np.uint8)
    r = min(corner_r, sh//2, sw//2)
    cv2.rectangle(mask, (r, 0),  (sw-r, sh),  255, -1)
    cv2.rectangle(mask, (0, r),  (sw, sh-r),  255, -1)
    for cx, cy in [(r,r),(sw-r,r),(r,sh-r),(sw-r,sh-r)]:
        cv2.circle(mask, (cx, cy), r, 255, -1)
    return mask

def _apply_highlight(img, x1, y1, x2, y2, color_bgr, alpha, blur_r):
    """Wendet Leuchtstift-Farbe auf ein Rechteck an."""
    x1 = max(0, x1); y1 = max(0, y1)
    x2 = min(img.shape[1], x2); y2 = min(img.shape[0], y2)
    sw, sh = x2-x1, y2-y1
    if sw <= 2 or sh <= 2: return

    mask = _pill_mask(sw, sh, sh//2)
    bk = max(5, (blur_r)|1)
    mask = cv2.GaussianBlur(mask.astype(np.float32), (bk*4+1, bk*4+1), bk*1.5)
    mask = (mask / mask.max() * 255).astype(np.uint8)

    roi = img[y1:y2, x1:x2]
    if roi.shape[:2] != (sh, sw): return
    col = np.full_like(roi, color_bgr)
    a   = (mask / 255.0 * alpha)[:, :, np.newaxis]
    img[y1:y2, x1:x2] = (col * a + roi * (1-a)).astype(np.uint8)

def highlight_accidental(img, x, y, w, h, band, color_bgr,
                          alpha=0.50, pad_x=12, pad_v=18):
    """Schmaler Highlighter über das Vorzeichen selbst (Schlüssel-Breite)."""
    sy1, sy2 = band
    x1 = x - pad_x
    y1 = sy1 - pad_v
    x2 = x + w + pad_x
    y2 = sy2 + pad_v
    sh = y2 - y1
    _apply_highlight(img, x1, y1, x2, y2, color_bgr, alpha, sh//4)

def highlight_notehead(img, nx, ny, nw, nh, color_bgr,
                        alpha=0.50, pad_x=10, pad_y=22):
    """Kleiner Highlighter über einen einzelnen Notenkopf."""
    # Vertikal etwas grosszügiger (Note hat Hals), horizontal eng am Kopf
    x1 = nx - pad_x
    y1 = ny - pad_y
    x2 = nx + nw + pad_x
    y2 = ny + nh + pad_y
    sh = y2 - y1
    _apply_highlight(img, x1, y1, x2, y2, color_bgr, alpha, sh//3)

# ─── Seiten-Analyse ──────────────────────────────────────────────────────────

SHARP_COLOR = (0,   210, 255)   # Gelb  → Kreuze
FLAT_COLOR  = (0,   255,  70)   # Grün  → Bs
KEY_THRESHOLD = 250             # x < 250 → Schlüssel-Vorzeichen

def analyze_page(page_img_bgr, flat_tpl, sharp_tpl,
                 flat_thr=0.83, sharp_thr=0.83):
    gray      = cv2.cvtColor(page_img_bgr, cv2.COLOR_BGR2GRAY)
    annotated = page_img_bgr.copy()
    bands     = detect_staff_bands(gray)
    noteheads = find_noteheads(gray)

    flat_hits_raw = find_symbol(gray, flat_tpl,  flat_thr)
    flat_hits  = [h for h in flat_hits_raw if is_real_flat(gray, *h[:4])]
    sharp_hits = find_symbol(gray, sharp_tpl, sharp_thr)

    n_key_sh = n_key_fl = 0

    def process_hits(hits, color):
        nonlocal n_key_sh, n_key_fl
        for (x, y, w, h, s) in hits:
            if not bands: continue
            band = find_staff(y + h//2, bands)

            # 1. Vorzeichen selbst markieren (schmaler Highlight)
            highlight_accidental(annotated, x, y, w, h, band, color)

            # 2. Wenn Schlüssel-Vorzeichen: zählen aber keine Note suchen
            if x < KEY_THRESHOLD:
                if color is SHARP_COLOR: n_key_sh += 1
                else: n_key_fl += 1
                continue

            # 3. Melodie-Vorzeichen: Note rechts davon markieren
            note = find_note_for_accidental(x, y, w, h, noteheads)
            if note:
                nx, ny, nw, nh = note
                highlight_notehead(annotated, nx, ny, nw, nh, color)

    process_hits(sharp_hits, SHARP_COLOR)
    process_hits(flat_hits,  FLAT_COLOR)

    n_sh = len(sharp_hits)
    n_fl = len(flat_hits)
    kl, kh = estimate_key(n_key_sh, n_key_fl)
    draw_key_box(annotated, kl, kh, n_key_sh, n_key_fl)
    return annotated, n_sh, n_fl, kl, kh

# ─── PDF ─────────────────────────────────────────────────────────────────────

def process_pdf(input_path, output_path=None, dpi=300,
                flat_thr=0.83, sharp_thr=0.83):
    input_path = Path(input_path)
    if not input_path.exists(): print(f"Nicht gefunden: {input_path}"); sys.exit(1)
    if output_path is None:
        output_path = input_path.with_name(input_path.stem + "_analysiert.pdf")

    flat_tpl, sharp_tpl = load_templates()
    doc = pymupdf.open(str(input_path))
    out_doc = pymupdf.open()
    total_sh = total_fl = 0

    for pg, page in enumerate(doc):
        print(f"  Seite {pg+1}/{len(doc)} ...", end=" ", flush=True)
        mat = pymupdf.Matrix(dpi/72, dpi/72)
        pix = page.get_pixmap(matrix=mat, colorspace=pymupdf.csRGB)
        img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, 3)
        img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)

        sc = dpi / 300.0
        ft = cv2.resize(flat_tpl,  (int(flat_tpl.shape[1]*sc), int(flat_tpl.shape[0]*sc)))
        st = cv2.resize(sharp_tpl, (int(sharp_tpl.shape[1]*sc), int(sharp_tpl.shape[0]*sc)))

        ann, n_sh, n_fl, kl, _ = analyze_page(img_bgr, ft, st, flat_thr, sharp_thr)
        total_sh += n_sh; total_fl += n_fl
        print(f"# {n_sh}x  b {n_fl}x  → {kl}")

        ann_rgb = cv2.cvtColor(ann, cv2.COLOR_BGR2RGB)
        pix_out = pymupdf.Pixmap(pymupdf.csRGB, pix.w, pix.h,
                                  ann_rgb.flatten().tobytes(), False)
        new_pg = out_doc.new_page(width=page.rect.width, height=page.rect.height)
        new_pg.insert_image(pymupdf.Rect(0,0,page.rect.width,page.rect.height), pixmap=pix_out)

    out_doc.save(str(output_path))
    doc.close(); out_doc.close()
    print(f"\nGesamt: {total_sh}x #  {total_fl}x b\nGespeichert: {output_path}")
    return str(output_path), total_sh, total_fl

def preview_page(input_path, page_num=0, dpi=300):
    flat_tpl, sharp_tpl = load_templates()
    doc = pymupdf.open(input_path)
    page = doc[page_num]
    mat = pymupdf.Matrix(dpi/72, dpi/72)
    pix = page.get_pixmap(matrix=mat, colorspace=pymupdf.csRGB)
    img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, 3)
    img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR); doc.close()
    sc = dpi/300.0
    ft = cv2.resize(flat_tpl,  (int(flat_tpl.shape[1]*sc), int(flat_tpl.shape[0]*sc)))
    st = cv2.resize(sharp_tpl, (int(sharp_tpl.shape[1]*sc), int(sharp_tpl.shape[0]*sc)))
    ann, n_sh, n_fl, kl, _ = analyze_page(img_bgr, ft, st)
    out = Path(input_path).with_name(Path(input_path).stem + f"_seite{page_num+1}_vorschau.png")
    cv2.imwrite(str(out), ann)
    print(f"Vorschau: {out}\n# {n_sh}x  b {n_fl}x  → {kl}")

def main():
    p = argparse.ArgumentParser(description="Noten-PDF Vorzeichen-Analyse (Leuchtstift)")
    p.add_argument("input"); p.add_argument("-o","--output")
    p.add_argument("--dpi", type=int, default=300)
    p.add_argument("--flat-schwelle",  type=float, default=0.83)
    p.add_argument("--sharp-schwelle", type=float, default=0.83)
    p.add_argument("--vorschau", action="store_true")
    p.add_argument("--seite", type=int, default=1)
    args = p.parse_args()
    if args.vorschau:
        preview_page(args.input, args.seite-1, args.dpi)
    else:
        process_pdf(args.input, args.output, args.dpi,
                    args.flat_schwelle, args.sharp_schwelle)

if __name__ == "__main__":
    main()
