"""Cross-page article-continuation linker (classical, no Claude / no API).

Telugu papers "jump" long stories across pages:

  • bottom of the source article :  'మిగతా <N>వ పేజీలో...'   (rest is on page N)
  • top of the continuation page  :  the SAME headline repeated, plus the marker
                                     '(మొదటి పేజీ తరువాయి)'   (continued from page 1)

We REQUIRE BOTH signals: the jump-from marker on the source page (which also tells
us the target page N) AND a repeated headline on page N that matches the source
article's headline. We then build a boundary box for the continuation by injecting
a SYNTHETIC dateline anchored on that repeated headline and running the existing
boundary engine (so the continuation floors correctly against page-N's own
articles), and finally STITCH the source crop + continuation crop into ONE tall
image per article.

Pure geometry + Tesseract on saved fixtures (page PNGs + regions.json + the
per-article info.json the pipeline already wrote). No Claude.

    link_continuations(run_dir) -> list of link records (also written to
        <run>/continuations/links.json, stitched PNGs alongside).
"""
import difflib
import json
import re
import unicodedata
from pathlib import Path

from PIL import Image

from _lines import detect_separator_lines

try:
    from smart_extractor import _dateline_in_text
except Exception:                       # keep the module importable in isolation
    def _dateline_in_text(_t, _paper=None):
        return None

_ZW = "‌‍"                       # zero-width non-joiner / joiner
_TERMINATORS = ".।?!"            # sentence-enders: full stop, danda, ?, !
_TELUGU_DIGITS = {ord("౦") + i: str(i) for i in range(10)}

# jump-FROM: 'మిగతా <num> వ పేజీ...' — tolerate OCR noise between the tokens.
_JUMP_FROM = re.compile(r"మిగ[తథధ]ా.{0,10}?([0-9౦-౯]{1,2})\s*వ?\s*పే[జీిౌ]", re.UNICODE)
# jump-TO: 'మొదటి పేజీ తరువాయి' — the continuation marker on the target page.
_JUMP_TO = re.compile(r"మొదట[ిి]?\s*పే[జీి].{0,6}?తరు[వ]?ా[యి]", re.UNICODE)


def _paper_of_height(h):
    if 7040 <= h <= 7060:
        return "sakshi"
    if h in (6833, 6549):
        return "andhra_jyothi"
    if h == 6422:
        return "namaste_telangana"
    return f"h{h}"


def _ocr(page, box):
    import pytesseract
    try:
        return pytesseract.image_to_string(page.crop((int(box[0]), int(box[1]),
                                                       int(box[2]), int(box[3]))),
                                            lang="tel")
    except Exception:
        return ""


def _page_num_from(text):
    m = _JUMP_FROM.search(text or "")
    if not m:
        return None
    digits = m.group(1).translate(_TELUGU_DIGITS)
    dm = re.search(r"\d+", digits)
    if not dm:
        return None
    d = dm.group()
    # OCR frequently DOUBLE-RECOGNISES a single glyph as the Latin digit followed
    # by its Telugu twin (e.g. '3౩' → '33'). District editions only ever run a
    # handful of pages, so a run of identical digits is that artefact, not page 33.
    if len(d) == 2 and d[0] == d[1]:
        d = d[0]
    return int(d)


def _norm(s):
    s = unicodedata.normalize("NFC", s or "")
    s = "".join(c for c in s if c not in _ZW)
    return re.sub(r"[\s.,!?:;\-()।\"'‘’]", "", s)


def _similar(a, b):
    a, b = _norm(a), _norm(b)
    if not a or not b:
        return 0.0
    return difflib.SequenceMatcher(None, a, b).ratio()


def _find_jump_from(page, art_bbox):
    """Scan the BOTTOM band of the source article for the 'మిగతా Nవ పేజీలో' marker;
    fall back to the whole article box. Returns the target page number or None."""
    l, t, r, b = art_bbox
    band_top = max(t, b - 340)
    n = _page_num_from(_ocr(page, (l, band_top, r, b)))
    if n is None:                                   # marker may sit a little higher
        n = _page_num_from(_ocr(page, (l, t, r, b)))
    return n


def _best_headline(regs, page, headline_tel, thresh=0.60):
    """Among the doc_titles on the target page, return (region, score, has_marker)
    for the one whose OCR text best matches the source headline (>= thresh)."""
    best, best_score = None, 0.0
    for r in regs:
        if r.get("type") != "doc_title":
            continue
        score = _similar(_ocr(page, r["bbox"]), headline_tel)
        if score > best_score:
            best, best_score = r, score
    if not best or best_score < thresh:
        return None, best_score, False
    hb = best["bbox"]
    below = _ocr(page, (hb[0], hb[3], hb[2], min(hb[3] + 240, page.height)))
    has_marker = bool(_JUMP_TO.search(below) or _JUMP_TO.search(_ocr(page, hb)))
    return best, best_score, has_marker


def _ends_terminated(text):
    """True if the column's last word ends a sentence (., danda, ?, !), ignoring
    trailing quotes/brackets. This is the (noisy, OCR-based) 'article concluded'
    signal — used only AFTER the structural stop-tests have had their say."""
    t = (text or "").rstrip()
    t = t.rstrip("\"'’”)］]》」』 ")
    return bool(t) and t[-1] in _TERMINATORS


def _col_width(regs):
    """Robust single-column width = median width of the page's text boxes."""
    ws = sorted(r["bbox"][2] - r["bbox"][0] for r in regs if r.get("type") == "text")
    return ws[len(ws) // 2] if ws else 700


def _has_body_below(regs, b):
    """True if a text box sits below box `b` within its horizontal span — i.e. `b`
    is a headline opening its OWN article (a new story), not a trailing label or an
    inline emphasis run. Used to let a paragraph_title act as an article floor."""
    for r in regs:
        if r.get("type") == "text":
            rb = r["bbox"]
            if rb[1] > b[1] + 5 and rb[0] < b[2] and rb[2] > b[0]:
                return True
    return False


def _is_floor_head(regs, r, hid):
    """A region that floors a continuation: any doc_title, OR a paragraph_title that
    has its own body beneath it (the start of a DIFFERENT article — e.g. a
    'continued from page N' sub-head sitting below our continuation block)."""
    if r.get("id") == hid:
        return False
    t = r.get("type")
    if t == "doc_title":
        return True
    return t == "paragraph_title" and _has_body_below(regs, r["bbox"])


def _floor_under(regs, lines, hb, hid, x0, x1, H):
    """Lowest boundary below the header within the corridor [x0,x1]: the top of the
    next headline (doc_title, or an article-starting paragraph_title), or a
    horizontal rule, whichever comes first (else page bottom)."""
    f = H
    for r in regs:
        b = r["bbox"]
        if (_is_floor_head(regs, r, hid)
                and b[1] > hb[3] + 20 and b[0] < x1 and b[2] > x0):
            f = min(f, b[1])
    for ln in (lines.get("h") or []):
        y = ln["y1"]
        if hb[3] + 20 < y < f and ln["x1"] < x1 and ln["x2"] > x0:
            f = min(f, y)
    return f


def _floor_below_body(regs, lines, hb, hid, x0, x1, body, H):
    """Lowest article boundary in corridor [x0,x1]. A next-headline or horizontal
    rule only counts as the floor when NO included body text continues *below* it in
    an overlapping column — that distinguishes the next article's wide headline (no
    body beneath, within this block) from an interior sub-head / column-top rule
    (article body keeps going underneath it)."""
    def _no_body_below(bx0, bx2, yt):
        for bb in body:
            if bb[3] > yt + 10 and bb[0] < bx2 and bb[2] > bx0:
                return False
        return True

    f = H
    for r in regs:
        b = r["bbox"]
        if (_is_floor_head(regs, r, hid)
                and b[1] > hb[3] + 20 and b[0] < x1 and b[2] > x0
                and _no_body_below(b[0], b[2], b[1])):
            f = min(f, b[1])
    for ln in (lines.get("h") or []):
        y = ln["y1"]
        if hb[3] + 20 < y < f and ln["x1"] < x1 and ln["x2"] > x0 \
                and _no_body_below(ln["x1"], ln["x2"], y):
            f = min(f, y)
    return f


def _vrule_in_gutter(lines, x0, x1, y0, y1):
    """A printed vertical rule sitting in the gutter [x0,x1] across most of [y0,y1]."""
    for ln in (lines.get("v") or []):
        if x0 - 8 <= ln["x1"] <= x1 + 8:
            ov = min(ln["y2"], y1) - max(ln["y1"], y0)
            if ov > 0.4 * (y1 - y0):
                return True
    return False


def _track_starts_with_title(regs, items, hb):
    """STOP-test 5: does a headline / sub-head box open the next column?"""
    xs = min(it["bbox"][0] for it in items)
    xe = max(it["bbox"][2] for it in items)
    ytop = min(it["bbox"][1] for it in items)
    for r in regs:
        if r.get("type") in ("doc_title", "paragraph_title"):
            b = r["bbox"]
            if b[0] < xe and b[2] > xs and b[1] > hb[3] + 20 and abs(b[1] - ytop) < 130:
                return True
    return False


def _continuation_crop(regs, png_path, paper, headline_region):
    """Crop the FULL continuation article by GROWING RIGHT from the headline.

    A continuation's body is usually wider than its header and spans several columns.
    We start at the header's column and walk right column-by-column, absorbing the
    next column unless a STOP-test fires:

      1 no next column            5 next column opens with a headline/sub-head
      2 vertical-rule wall        6 current column's last word ends with a period
      3 horizontal-rule break     7 grammatical bridge fails (approx: 4+6 absent)
      4 next column opens a dateline

    Structural tests (1-5) outrank the OCR period (6). The crop is the single
    rectangle enclosing every absorbed column (+ any photo inside the block),
    floored at the next headline / horizontal rule.

    Returns (PIL.Image, debug_bbox) or (None, None)."""
    page = Image.open(png_path).convert("RGB")
    H = page.height
    hb = headline_region["bbox"]
    hid = headline_region.get("id")
    cx = (hb[0] + hb[2]) / 2
    lines = detect_separator_lines(png_path)
    colW = _col_width(regs)

    # provisional floor under the header's own span gives us the band to cluster in
    F0 = _floor_under(regs, lines, hb, hid, hb[0], hb[2], H)

    # body text boxes in the band, clustered into left-to-right COLUMN TRACKS
    band = [r for r in regs if r.get("type") == "text"
            and (r["bbox"][1] + r["bbox"][3]) / 2 < F0
            and r["bbox"][3] > hb[1]
            and r["bbox"][0] >= hb[0] - colW * 0.5]   # drop columns left of the header
    if not band:
        return None, None
    tracks = []
    for r in sorted(band, key=lambda r: r["bbox"][0]):
        for tr in tracks:
            if abs(r["bbox"][0] - tr["x0"]) < colW * 0.5:
                tr["items"].append(r)
                break
        else:
            tracks.append({"x0": r["bbox"][0], "items": [r]})
    tracks.sort(key=lambda t: t["x0"])

    # START column = the track straddling the headline centre (else nearest)
    start = None
    for i, t in enumerate(tracks):
        if min(it["bbox"][0] for it in t["items"]) <= cx <= max(it["bbox"][2] for it in t["items"]):
            start = i
            break
    if start is None:
        start = min(range(len(tracks)), key=lambda i: abs(tracks[i]["x0"] - cx))

    import os
    DBG = os.environ.get("CONT_DEBUG")
    if DBG:
        print(f"[dbg] hb={hb} cx={cx} colW={colW} F0={F0}")
        for ti, t in enumerate(tracks):
            print(f"[dbg] track {ti} x0={t['x0']} items="
                  + ",".join(f"{it.get('id')}{it['bbox']}" for it in t['items']))
        print(f"[dbg] start track = {start}")

    # GROW RIGHT, applying the stop-tests at each gutter
    included = [start]
    i = start
    while True:
        cur = tracks[i]["items"]
        cur_r = max(it["bbox"][2] for it in cur)
        nxt = next((j for j in range(i + 1, len(tracks))
                    if tracks[j]["x0"] > cur_r - colW * 0.3), None)
        if nxt is None:                                         # 1 no next column
            if DBG: print(f"[dbg] from {i}: STOP test1 no next column")
            break
        nt = tracks[nxt]["items"]
        nt_l = min(it["bbox"][0] for it in nt)
        y0, y1 = hb[1], F0
        if _vrule_in_gutter(lines, cur_r, nt_l, y0, y1):        # 2 vertical wall
            if DBG: print(f"[dbg] from {i} to {nxt}: STOP test2 vrule wall {cur_r}-{nt_l}")
            break
        top_reg = min(nt, key=lambda it: it["bbox"][1])
        tb = top_reg["bbox"]
        head_txt = _ocr(page, (tb[0], tb[1], tb[2], min(tb[1] + 150, tb[3])))
        if _dateline_in_text(head_txt, paper):                  # 4 dateline opens next col
            if DBG: print(f"[dbg] from {i} to {nxt}: STOP test4 dateline '{head_txt[:40]}'")
            break
        if _track_starts_with_title(regs, nt, hb):              # 5 headline opens next col
            if DBG: print(f"[dbg] from {i} to {nxt}: STOP test5 title opens next")
            break
        bot_reg = max(cur, key=lambda it: it["bbox"][3])
        bot_txt = _ocr(page, bot_reg["bbox"])
        if _ends_terminated(bot_txt):                           # 6 period concludes
            if DBG: print(f"[dbg] from {i} to {nxt}: STOP test6 period; tail='{bot_txt[-30:]}'")
            break
        if DBG: print(f"[dbg] grow {i} -> {nxt}")
        included.append(nxt)                                    # 7 bridge holds -> grow
        i = nxt
    if DBG: print(f"[dbg] included tracks = {included}")

    # FINAL rectangle over the absorbed columns, floored against the real corridor
    left = hb[0]
    right = max(max(it["bbox"][2] for it in tracks[k]["items"]) for k in included)
    body = [it["bbox"] for k in included for it in tracks[k]["items"]]
    F = _floor_below_body(regs, lines, hb, hid, left, right, body, H)
    if DBG: print(f"[dbg] left={left} right={right} F={F}")

    members = list(body)
    for r in regs:                       # sweep in photos / captions inside the block
        if r.get("type") in ("image", "figure_title", "paragraph_title", "doc_title"):
            b = r["bbox"]
            bcx = (b[0] + b[2]) / 2
            if left - 10 <= bcx <= right + 10 and b[3] <= F and b[3] > hb[1] - 320:
                members.append(b)

    left = min(left, min(m[0] for m in members))
    top = min(hb[1], min(m[1] for m in members))
    bot = min(int(F) - 8, max(m[3] for m in members) + 22)
    box = (int(left), int(max(0, top - 6)), int(right), int(bot))
    return page.crop(box), list(box)


def _stitch(top_png, cont_img, out_path):
    """Stack the source page-1 crop above the composed continuation image."""
    top = Image.open(top_png).convert("RGB")
    W = max(top.width, cont_img.width)
    sep = 18
    combo = Image.new("RGB", (W, top.height + sep + cont_img.height), "white")
    combo.paste(top, (0, 0))
    combo.paste(cont_img, (0, top.height + sep))
    combo.save(out_path)
    return combo.size


def link_continuations(run_dir, out_dir=None, match_thresh=0.60, verbose=True):
    run = Path(run_dir)
    pages_dir = run / "pages"
    arts_dir = run / "articles"
    out_dir = Path(out_dir) if out_dir else (run / "continuations")
    out_dir.mkdir(parents=True, exist_ok=True)

    p1 = pages_dir / "page_001.png"
    paper = _paper_of_height(Image.open(p1).height) if p1.exists() else "unknown"

    results = []
    for info_path in sorted(arts_dir.glob("*/info.json")):
        info = json.loads(info_path.read_text())
        P, bbox = info.get("page"), info.get("bbox")
        headline = info.get("headline_telugu") or ""
        if not P or not bbox:
            continue
        # Continuations only ever START on page 1 — the front page jumps long lead
        # stories onto inside pages. Articles on pages 2+ are self-contained, so we
        # never scan them for a jump-from marker.
        if P != 1:
            continue
        src_png = pages_dir / f"page_{P:03d}.png"
        if not src_png.exists():
            continue

        N = _find_jump_from(Image.open(src_png), bbox)        # SIGNAL 1: jump-from
        if not N or N == P:
            continue

        # The jump-from page DIGIT is OCR-noisy (Telugu ౪/౬ confusion) and can point
        # at a page outside this extraction (here the front page said '6వ పేజీ' but
        # the run only has 4 pages). So treat the marker as "this front-page lead
        # continues on a later page" and resolve the ACTUAL target by the repeated-
        # headline signal: score every OTHER page's doc_titles against the source
        # headline and take the best match, breaking ties toward the OCR'd page N.
        existing = sorted(
            int(m.group(1))
            for q in pages_dir.glob("page_*.png")
            if (m := re.match(r"page_(\d+)\.png$", q.name)))
        best = None   # (score, page, tregs, tgt_png_str, hl, marker)
        for q in existing:
            if q == P:
                continue
            qr = pages_dir / f"page_{q:03d}.regions.json"
            qp = pages_dir / f"page_{q:03d}.png"
            if not qr.exists() or not qp.exists():
                continue
            qregs = json.loads(qr.read_text())["regions"]
            hl_q, score_q, marker_q = _best_headline(qregs, Image.open(qp), headline, match_thresh)
            if hl_q is None:
                continue
            if best is None or score_q > best[0] or (score_q == best[0] and q == N):
                best = (score_q, q, qregs, str(qp), hl_q, marker_q)

        if best is None:
            rec = {"article": info_path.parent.name, "src_page": P, "headline": headline,
                   "target_page": N, "match_score": 0.0, "taruvaayi_marker": False,
                   "linked": False,
                   "reason": "no matching repeated headline on any inside page"}
            results.append(rec)
            if verbose:
                print(f"  ✗ {rec['article']} → p{N}: jump-from found but no repeated "
                      f"headline match on any inside page (NOT linked)")
            continue

        score, N, tregs, tgt_png, hl, marker = best
        rec = {"article": info_path.parent.name, "src_page": P, "headline": headline,
               "target_page": N, "match_score": round(score, 2),
               "taruvaayi_marker": marker, "linked": False}

        cont_img, cbox = _continuation_crop(tregs, tgt_png, paper, hl)
        if cont_img is None:
            rec["reason"] = "no body under repeated headline"
            results.append(rec)
            continue
        out_png = out_dir / f"{info_path.parent.name}__cont_p{N:03d}.png"
        size = _stitch(arts_dir / info_path.parent.name / "article.png",
                       cont_img, out_png)
        rec.update({"linked": True, "cont_bbox": cbox,
                    "stitched": str(out_png), "stitched_size": size})
        results.append(rec)
        if verbose:
            print(f"  ✓ {rec['article']} '{headline[:26]}' → p{N} "
                  f"(headline {score:.2f}, తరువాయి={marker}) → {out_png.name}")

    (out_dir / "links.json").write_text(json.dumps(results, indent=2, ensure_ascii=False))
    linked = sum(1 for r in results if r["linked"])
    if verbose:
        print(f"\n  {linked} continuation(s) linked & stitched; report: {out_dir/'links.json'}")
    return results


if __name__ == "__main__":
    import sys
    link_continuations(sys.argv[1] if len(sys.argv) > 1 else "output/20260601_234709")
