
"""1099-NEC (tax year 2025) parser.

Strategy (first one that finds data wins, later ones only fill gaps):
  1. LAYOUT  – use word coordinates (PDF words, or OCR words for scans).
  2. VALUE-BLOCK – templates whose stream order is values first, labels after.
  3. LABEL-BASED – templates where values follow their labels in the text.

Recipient-name extraction is validated across all strategies to prevent
amounts and form-heading text from being returned as a person's name.
"""

import re

import fitz  # PyMuPDF
import pytesseract


ALL_FIELDS = (
    "tax_year", "account_number",
    "payer_tin", "payer_name", "payer_address", "payer_phone",
    "recipient_tin", "recipient_name", "recipient_address",
    "box1_nonemployee_compensation", "box2_direct_sales",
    "box3_excess_golden", "box4_fed_tax_withheld",
    "box5_state_tax_withheld", "box6_state_payer_no", "box7_state_income",
)

TIN_RE = re.compile(r"\b(\d{2}-\d{7}|\d{3}-\d{2}-\d{4}|\d{9})\b")

_TIN_WORD_RE = re.compile(
    r"\d{2}-\d{7}|\d{3}-\d{2}-\d{4}|\d{9}"
    r"|[X*]{3}-[X*]{2}-\d{4}|[X*]{2}-[X*]{3}\d{4}",
    re.I,
)

_AMOUNT_RE = re.compile(r"^\$?\d[\d,]*\.\d{2}$")

_PHONE_RE = re.compile(
    r"(?<!\d)(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?!\d)"
)

_PHONE_LABEL_RE = re.compile(r"\b(?:tel(?:ephone)?|phone|ph)\b\.?:?", re.I)

_CHECK_MARKS = {"x", "✓", "✔", "☒", "☑", "■"}

BOILERPLATE_PHRASES = [
    "see instructions", "this is important tax", "information and is",
    "being furnished to", "the irs", "omb no", "corrected", "copy b",
    "keep for your records", "instructions for recipients",
    "street address", "city or town", "state or province",
    "foreign postal code", "zip or foreign", "telephone no",
]

_SANITY_MARKERS = (
    "nonemployee", "compensation", "$",
    "form 1099", "for recipient", "for reciepient",
    "payer's", "recipient's",
)

PAGE_W, PAGE_H = 612.0, 792.0


# ─────────────────────── small helpers ───────────────────────

def clean(value):
    if value is None:
        return None
    return re.sub(r"\s+", " ", str(value)).strip() or None


def _is_boilerplate(text):
    if not text:
        return True
    lower = text.lower()
    return any(p in lower for p in BOILERPLATE_PHRASES)


def _parse_amount(val_str):
    if not val_str:
        return None

    m = re.search(r"([\d,]+\.\d{2})", str(val_str))

    if m:
        try:
            return round(float(m.group(1).replace(",", "")), 2)
        except ValueError:
            return None

    return None


def _fill_missing(result, extra):
    for key, value in extra.items():
        if result.get(key) in (None, "") and value not in (None, ""):
            result[key] = value


def _valid_recipient_name(value):
    """Return a clean recipient name, or None if the value is not valid."""

    value = clean(value)

    if not value:
        return None

    # Remove labels accidentally appended to a name.
    value = re.split(
        r"\b(?:recipient['’`]?\s*name|street address|city or town|"
        r"state or province|foreign postal code|ZIP(?: code)?|"
        r"account number|FATCA|instructions)\b",
        value,
        maxsplit=1,
        flags=re.IGNORECASE,
    )[0]

    value = clean(value)

    if not value:
        return None

    # A monetary value anywhere in the candidate makes it invalid.
    if "$" in value or re.search(
        r"(?<![A-Za-z])\d[\d,]*\.\d{2}(?!\w)",
        value,
    ):
        return None

    # Reject TINs, including masked TINs.
    if _TIN_WORD_RE.search(value):
        return None

    # Reject form labels and headings, including common OCR misspellings.
    if re.search(
        r"\b(?:recipient|payer|street address|city or town|"
        r"state or province|foreign postal|account number|"
        r"FATCA|instructions|nonemployee|compensation|"
        r"1099|for\s+rec(?:i|ie)pient)\b",
        value,
        re.IGNORECASE,
    ):
        return None

    if _is_boilerplate(value):
        return None

    # A person's/company's name should contain letters.
    if not re.search(r"[A-Za-z]", value):
        return None

    # Reject values made mostly of numeric or punctuation characters.
    if not re.search(r"[A-Za-z]{2,}", value):
        return None

    return value



def _recover_recipient_name_from_pdf(pdf_bytes):
    """Recover recipient name from its physical position on the PDF."""
    if not pdf_bytes or not pdf_bytes.startswith(b"%PDF"):
        return None

    try:
        doc = fitz.open(stream=pdf_bytes, filetype="pdf")
    except Exception:
        return None

    try:
        for page in doc:
            page_w = page.rect.width
            page_h = page.rect.height

            words = [
                _word(*w[:5])
                for w in (page.get_text("words") or [])
            ]

            if not words:
                continue

            lines = _group_lines(words)

            # Restrict the search to the recipient's left-hand column.
            nonemployee_words = [
                w for w in words
                if re.search(r"nonemploy", w["text"], re.I)
            ]

            split_x = (
                min(w["x0"] for w in nonemployee_words) - 14
                if nonemployee_words
                else page_w * 0.46
            )
            split_x = max(page_w * 0.35, min(page_w * 0.60, split_x))

            left_lines = _group_lines([
                w for w in words
                if w["x0"] < split_x
            ])

            if not left_lines:
                continue

            # Find the printed recipient-name label.
            
            label_index = _find_line(
                left_lines,
                lambda t: (
                    re.search(r"\brecipient\b", t, re.I) is not None
                    and re.search(r"\bname\b", t, re.I) is not None
                    and not re.search(r"\btin\b", t, re.I)
                ),
            )


            if label_index is None:
                continue

            label = left_lines[label_index]

            # Identify boundaries so the search cannot leak into
            # the recipient address, account number, or footer.
            boundary_indices = [
                i for i, line in enumerate(left_lines)
                if i > label_index
                and re.search(
                    r"recipient.*address|city or town|"
                    r"state or province|foreign postal|"
                    r"account number|FATCA|instructions",
                    line["text"],
                    re.I,
                )
            ]

            next_boundary = (
                min(boundary_indices)
                if boundary_indices
                else len(left_lines)
            )

            # First look immediately above the label. Many forms print
            # the recipient's name in the box above its caption.
            above_candidates = [
                line for line in left_lines[:label_index]
                if line["y1"] <= label["y0"] + 2
                and label["y0"] - line["y1"] <= 65
            ]

            for line in reversed(above_candidates):
                text = clean(line["text"])

                # Reject lines that are clearly other field labels.
                if re.search(
                    r"payer|recipient|TIN|address|"
                    r"city or town|state or province|"
                    r"foreign postal|account number|"
                    r"1099|nonemployee|compensation|"
                    r"telephone|phone|FATCA|instructions",
                    text or "",
                    re.I,
                ):
                    continue

                candidate = _valid_recipient_name(text)
                if candidate:
                    return candidate

            # Next, check text printed on the same line after "Name".
            label_words = label["words"]
            name_index = next(
                (
                    i for i, word in enumerate(label_words)
                    if word["text"].strip(":'’`").lower() == "name"
                ),
                None,
            )

            if name_index is not None:
                inline = clean(
                    " ".join(
                        word["text"]
                        for word in label_words[name_index + 1:]
                    )
                )
                candidate = _valid_recipient_name(inline)

                if candidate:
                    return candidate

            # Finally, check lines below the label but before the
            # next field boundary.
            for line in left_lines[label_index + 1:next_boundary]:
                if line["y0"] - label["y1"] > 65:
                    break

                text = clean(line["text"])

                if re.search(
                    r"payer|recipient|TIN|address|"
                    r"city or town|state or province|"
                    r"foreign postal|account number|"
                    r"1099|nonemployee|compensation|"
                    r"telephone|phone|FATCA|instructions",
                    text or "",
                    re.I,
                ):
                    continue

                candidate = _valid_recipient_name(text)
                if candidate:
                    return candidate

    except Exception:
        return None
    finally:
        doc.close()

    return None
  
# ─────────────────────── text / word extractors ───────────────────────

def _word(x0, y0, x1, y1, text):
    return {
        "x0": float(x0),
        "y0": float(y0),
        "x1": float(x1),
        "y1": float(y1),
        "text": str(text),
    }


def extract_text(pdf_bytes):
    """Per-page text in stream order plus word boxes."""
    pages = []
    document = fitz.open(stream=pdf_bytes, filetype="pdf")

    try:
        for index, page in enumerate(document):
            pages.append({
                "page": index + 1,
                "text": page.get_text("text", sort=True) or "",
                "words": [
                    _word(*w[:5])
                    for w in (page.get_text("words") or [])
                ],
                "width": page.rect.width,
                "height": page.rect.height,
                "source": "pdf_text",
            })
    finally:
        document.close()

    return pages


def _ocr_words(image):
    """Word boxes from an image via Tesseract, scaled to PDF points."""
    d = pytesseract.image_to_data(
        image,
        output_type=pytesseract.Output.DICT,
    )

    sx, sy = PAGE_W / image.width, PAGE_H / image.height
    words = []

    for i, txt in enumerate(d["text"]):
        txt = (txt or "").strip()

        if not txt:
            continue

        try:
            left = int(d["left"][i])
            top = int(d["top"][i])
            width = int(d["width"][i])
            height = int(d["height"][i])
        except (ValueError, IndexError, KeyError):
            continue

        words.append(
            _word(
                left * sx,
                top * sy,
                (left + width) * sx,
                (top + height) * sy,
                txt,
            )
        )

    return words, PAGE_W, PAGE_H


def _iter_word_sources(pages, pdf_bytes, warnings):
    """Yield (words, width, height) for each page, best source first."""
    if pdf_bytes and pdf_bytes[:4] == b"%PDF":
        sources = []

        try:
            doc = fitz.open(stream=pdf_bytes, filetype="pdf")

            try:
                for page in doc:
                    words = [
                        _word(*w[:5])
                        for w in (page.get_text("words") or [])
                    ]
                    sources.append(
                        (words, page.rect.width, page.rect.height)
                    )
            finally:
                doc.close()

        except Exception as exc:
            warnings.append(f"PDF word extraction failed: {exc}")

        for src in sources:
            yield src

    for p in pages or []:
        if p and p.get("words"):
            yield (
                p["words"],
                p.get("width") or PAGE_W,
                p.get("height") or PAGE_H,
            )

    for p in pages or []:
        if p and p.get("image") is not None:
            try:
                yield _ocr_words(p["image"])
            except Exception as exc:
                warnings.append(f"OCR failed: {exc}")


# ─────────────────────── layout parser helpers ───────────────────────

def _group_lines(words, tol=4.0):
    """Group words into visual lines, top-to-bottom and left-to-right."""
    lines = []

    for w in sorted(
        words,
        key=lambda w: ((w["y0"] + w["y1"]) / 2, w["x0"]),
    ):
        cy = (w["y0"] + w["y1"]) / 2

        if lines and abs(cy - lines[-1]["cy"]) <= tol:
            ln = lines[-1]
            ln["words"].append(w)
            n = len(ln["words"])
            ln["cy"] = (ln["cy"] * (n - 1) + cy) / n
        else:
            lines.append({"cy": cy, "words": [w]})

    out = []

    for ln in lines:
        ws = sorted(ln["words"], key=lambda w: w["x0"])

        out.append({
            "words": ws,
            "text": clean(" ".join(w["text"] for w in ws)) or "",
            "x0": ws[0]["x0"],
            "y0": min(w["y0"] for w in ws),
            "y1": max(w["y1"] for w in ws),
        })

    return out


def _find_line(lines, pred, start=0):
    for i in range(start, len(lines)):
        t = lines[i]["text"].lower().replace("’", "'")

        if pred(t):
            return i

    return None


def _between(lines, top, bottom):
    """Lines that sit fully between two y positions."""
    return [
        line for line in lines
        if line["y0"] >= top - 1 and line["y1"] <= bottom + 1
    ]


def _split_payer(texts):
    """Extract payer name, mailing address, and phone."""
    phone = None
    keep = []

    for text in texts:
        text = clean(text)

        if not text:
            continue

        match = _PHONE_RE.search(text)

        if match:
            phone = phone or clean(match.group(0))
            text = clean(
                _PHONE_LABEL_RE.sub(
                    "",
                    text[:match.start()] + " " + text[match.end():],
                )
            )

        if not text:
            continue

        text = re.sub(
            r"\b(?:Form\s+)?1099[-\s]?NEC\b.*$",
            "",
            text,
            flags=re.IGNORECASE,
        )

        text = re.sub(
            r"\bNonemployee\s+Compensation\b.*$",
            "",
            text,
            flags=re.IGNORECASE,
        )

        text = re.sub(
            r"\bFor\s+Rec(?:i|ie)pient\b.*$",
            "",
            text,
            flags=re.IGNORECASE,
        )

        text = clean(text)

        if text and not _is_boilerplate(text):
            keep.append(text)

    if not keep:
        return None, None, phone

    first = keep[0]

    po_match = re.search(
        r"\bP\.?\s*O\.?\s*Box\b",
        first,
        re.IGNORECASE,
    )

    if po_match:
        name = clean(first[:po_match.start()])
        rest = [clean(first[po_match.start():])]
        rest.extend(keep[1:])

    else:
        address_index = next(
            (
                i for i, line in enumerate(keep)
                if re.search(
                    r"\b(?:P\.?\s*O\.?\s*Box|"
                    r"\d{1,6}\s+[A-Za-z])\b",
                    line,
                    re.IGNORECASE,
                )
            ),
            None,
        )

        if address_index is not None:
            name = clean(" ".join(keep[:address_index]))
            rest = keep[address_index:]
        else:
            name = first
            rest = keep[1:]

    if name and re.search(
        r"\b(?:nonemployee|compensation|recipient|1099)\b",
        name,
        re.IGNORECASE,
    ):
        name = re.sub(
            r"\b(?:nonemployee|compensation|recipient|1099[-\s]?NEC)\b",
            "",
            name,
            flags=re.IGNORECASE,
        )
        name = clean(name)

    address = ", ".join(filter(None, rest)) or None

    return name or None, address, phone


# ─────────────────────── layout parser ───────────────────────

def _parse_layout(words, page_w, page_h):
    """Locate values by their positions relative to printed labels."""
    if not words:
        return None

    footer_y = page_h

    keeps = [
        w["y0"] for w in words
        if w["text"].lower().strip("()") == "keep"
        and w["y0"] > page_h * 0.3
    ]

    if keeps:
        footer_y = min(keeps) - 2
    else:
        recips = [
            w["y0"] for w in words
            if w["text"].lower() == "recipients"
            and w["y0"] > page_h * 0.25
        ]

        if recips:
            footer_y = min(recips) - 15

    form = [w for w in words if w["y1"] <= footer_y + 1]

    nonemp = [
        w for w in form
        if w["text"].lower().startswith("nonemploy")
    ]

    split_x = (
        min(w["x0"] for w in nonemp) - 14
        if nonemp else page_w * 0.46
    )
    split_x = max(page_w * 0.35, min(page_w * 0.6, split_x))

    left_words = [w for w in form if w["x0"] < split_x]
    right_words = [w for w in form if w["x0"] >= split_x]
    L = _group_lines(left_words)

    found = {k: None for k in ALL_FIELDS}

    # Label anchors.
    i_pn = _find_line(
        L,
        lambda t: "payer" in t and "name" in t,
    )

    i_pn_end = None

    if i_pn is not None:
        i_pn_end = _find_line(
            L,
            lambda t: "telephone" in t,
            i_pn,
        )

        if i_pn_end is None:
            i_pn_end = i_pn

    i_ptin = _find_line(
        L,
        lambda t: "payer" in t and re.search(r"\btin\b", t),
    )

    i_rn = _find_line(
        L,
        lambda t: "recipient" in t and "name" in t,
    )

    i_ra = _find_line(
        L,
        lambda t: "recipient" in t and "address" in t,
    )

    i_city = _find_line(
        L,
        lambda t: t.startswith("city or town"),
        i_ra if i_ra is not None else 0,
    )

    i_city_end = i_city

    if i_city is not None and i_city + 1 < len(L):
        if (
            "postal" not in L[i_city]["text"].lower()
            and "postal" in L[i_city + 1]["text"].lower()
        ):
            i_city_end = i_city + 1

    i_acct = _find_line(
        L,
        lambda t: "account number" in t,
    )

    i_fatca = _find_line(
        L,
        lambda t: "fatca" in t,
        i_city if i_city is not None else 0,
    )

    # Payer name, address, phone.
    if i_pn is not None:
        end_candidates = [
            L[i]["y0"]
            for i in (i_ptin, i_rn)
            if i is not None
        ]

        end_y = min(end_candidates) if end_candidates else footer_y

        zone = _between(
            L,
            L[i_pn_end]["y1"],
            end_y,
        )

        name, addr, phone = _split_payer(
            [line["text"] for line in zone]
        )

        found["payer_name"] = name
        found["payer_address"] = addr
        found["payer_phone"] = phone

    # TINs.
    tin_words = [
        w for w in left_words
        if _TIN_WORD_RE.fullmatch(w["text"].strip(",.;:"))
    ]

    tin_words.sort(
        key=lambda w: (round(w["y0"] / 6), w["x0"])
    )

    p_x = r_x = None

    if i_ptin is not None:
        for w in L[i_ptin]["words"]:
            t = w["text"].lower()

            if t.startswith("payer") and p_x is None:
                p_x = w["x0"]

            if t.startswith("recipient") and r_x is None:
                r_x = w["x0"]

    payer_tin = recip_tin = None

    if p_x is not None and r_x is not None:
        for w in tin_words:
            if abs(w["x0"] - p_x) <= abs(w["x0"] - r_x):
                payer_tin = payer_tin or w["text"].strip(",.;:")
            else:
                recip_tin = recip_tin or w["text"].strip(",.;:")

    if len(tin_words) >= 2 and (
        payer_tin is None or recip_tin is None
    ):
        payer_tin = tin_words[0]["text"].strip(",.;:")
        recip_tin = tin_words[1]["text"].strip(",.;:")

    elif len(tin_words) == 1 and (
        payer_tin is None and recip_tin is None
    ):
        payer_tin = tin_words[0]["text"].strip(",.;:")

    found["payer_tin"] = payer_tin
    found["recipient_tin"] = recip_tin

    # Recipient name.
    # Values may be above, below, or inline with the printed label.
    if i_rn is not None:
        label_line = L[i_rn]

        def is_name_candidate(text):
            return _valid_recipient_name(text)

        label_words = label_line["words"]

        name_idx = next(
            (
                i for i, word in enumerate(label_words)
                if word["text"].strip(":'’`").lower() == "name"
            ),
            None,
        )

        candidate = None

        # 1. Value after the label on the same line.
        if name_idx is not None:
            inline = clean(
                " ".join(
                    w["text"] for w in label_words[name_idx + 1:]
                )
            )

            candidate = is_name_candidate(inline)

        # 2. Value above the label.
        if not candidate:
            upper_bound = 0

            if i_ptin is not None:
                upper_bound = L[i_ptin]["y1"]

            above = [
                line for line in L
                if line["y0"] >= upper_bound - 1
                and line["y1"] <= label_line["y0"] + 1
                and line is not label_line
            ]

            for line in reversed(above):
                candidate = is_name_candidate(line["text"])

                if candidate:
                    break

        # 3. Value below the label, stopping before address labels.
        if not candidate:
            lower_bounds = [
                L[i]["y0"]
                for i in (i_ra, i_city)
                if i is not None
            ]

            end_y = min(lower_bounds) if lower_bounds else footer_y

            below = _between(
                L,
                label_line["y1"],
                end_y,
            )

            for line in below:
                candidate = is_name_candidate(line["text"])

                if candidate:
                    break

        if candidate:
            found["recipient_name"] = candidate

    # Recipient address.
    parts = []

    if i_ra is not None:
        end_y = (
            L[i_city]["y0"]
            if i_city is not None
            else footer_y
        )

        parts += [
            line["text"]
            for line in _between(L, L[i_ra]["y1"], end_y)
        ]

    if i_city is not None:
        nxt = [
            L[i]["y0"]
            for i in (i_fatca, i_acct)
            if i is not None
        ]

        end_y = min(nxt) if nxt else footer_y

        parts += [
            line["text"]
            for line in _between(
                L,
                L[i_city_end]["y1"],
                end_y,
            )
        ]

    parts = [p for p in parts if p]
    found["recipient_address"] = ", ".join(parts) if parts else None

    # Account number.
    if i_acct is not None:
        zone = _between(L, L[i_acct]["y1"], footer_y)
        val = clean(" ".join(line["text"] for line in zone))

        if not val:
            after = []
            seen = False

            for w in L[i_acct]["words"]:
                if seen:
                    after.append(w["text"])

                if "instructions" in w["text"].lower():
                    seen = True

            val = clean(" ".join(after))

        found["account_number"] = val

    # Tax year.
    years = [
        w for w in form
        if re.fullmatch(r"20\d{2}", w["text"])
    ]

    if years:
        titles = [
            w for w in form
            if "1099-nec" in w["text"].lower()
        ]

        if titles:
            t = titles[0]
            years.sort(
                key=lambda w: (
                    abs(w["x0"] - t["x0"])
                    + abs(w["y0"] - t["y0"])
                )
            )
        else:
            years.sort(key=lambda w: (w["y0"], -w["x0"]))

        found["tax_year"] = int(years[0]["text"])

    # Right column: boxes.
    rw = right_words

    amounts = sorted(
        (
            w for w in rw
            if _AMOUNT_RE.match(w["text"].strip())
        ),
        key=lambda w: (w["y0"], w["x0"]),
    )

    a1 = min(nonemp, key=lambda w: w["x0"]) if nonemp else None

    fed = [
        w for w in rw
        if w["text"].lower() == "federal"
    ]

    a4 = min(fed, key=lambda w: w["y0"]) if fed else None

    b2 = [
        w for w in rw
        if w["text"] == "2"
        and split_x - 4 <= w["x0"] <= split_x + 18
        and (a1 is None or w["y0"] > a1["y1"])
    ]

    b2 = min(b2, key=lambda w: w["y0"]) if b2 else None

    row_words, row_y, row_bottom = [], None, None

    st = [
        w for w in rw
        if w["text"].lower().startswith("state/payer")
    ]

    if st:
        row_y = st[0]["y0"]

        row_words = sorted(
            (
                w for w in rw
                if abs(w["y0"] - row_y) <= 3
            ),
            key=lambda w: w["x0"],
        )

        row_bottom = max(w["y1"] for w in row_words)

    def first_amount(y_from, y_to):
        for w in amounts:
            if w["y0"] >= y_from - 2 and w["y0"] < y_to:
                return _parse_amount(w["text"])

        return None

    limit4 = (
        a4["y0"]
        if a4
        else (row_y if row_y is not None else footer_y)
    )

    limit1 = min(
        v for v in (
            b2["y0"] if b2 else None,
            limit4,
        )
        if v is not None
    )

    found["box1_nonemployee_compensation"] = first_amount(
        a1["y0"] if a1 else -1e9,
        limit1,
    )

    if a4:
        found["box4_fed_tax_withheld"] = first_amount(
            a4["y0"],
            row_y if row_y is not None else footer_y,
        )

    # Box 2: direct-sales checkbox.
    if b2:
        zone_end = (
            a4["y0"]
            if a4
            else (row_y if row_y is not None else footer_y)
        )

        if any(
            w["text"].strip().lower() in _CHECK_MARKS
            and b2["y0"] <= w["y0"] < zone_end
            for w in rw
        ):
            found["box2_direct_sales"] = True

    # Boxes 5–7: state row.
    if row_words:
        groups, cur = [], []

        for w in row_words:
            if re.fullmatch(r"\d{1,2}", w["text"]) and cur:
                groups.append(cur)
                cur = []

            cur.append(w)

        groups.append(cur)

        if len(groups) == 3:
            x6 = groups[1][0]["x0"] - 6
            x7 = groups[2][0]["x0"] - 8
        else:
            x_min = row_words[0]["x0"]
            x_max = max(w["x1"] for w in row_words)

            x6 = x_min + (x_max - x_min) / 3
            x7 = x_min + 2 * (x_max - x_min) / 3

        vals = [
            w for w in rw
            if w["y0"] >= row_bottom - 1
        ]

        for row in _group_lines(vals):
            real = [
                w for w in row["words"]
                if w["text"].strip() != "$"
            ]

            if not real:
                continue

            c5 = [
                w["text"] for w in real
                if w["x0"] < x6
            ]

            c6 = [
                w["text"] for w in real
                if x6 <= w["x0"] < x7
            ]

            c7 = [
                w["text"] for w in real
                if w["x0"] >= x7
            ]

            found["box5_state_tax_withheld"] = _parse_amount(
                " ".join(c5)
            )

            found["box6_state_payer_no"] = clean(" ".join(c6))
            found["box7_state_income"] = _parse_amount(" ".join(c7))
            break

    # Confirm that this resembles a 1099-NEC layout.
    if not (
        found["payer_tin"]
        or found["recipient_tin"]
        or found["box1_nonemployee_compensation"] is not None
    ):
        return None

    return found


# ─────────────────────── text fallbacks ───────────────────────

def _extract_value_block(raw_text, result):
    """Parse templates whose values appear before their labels."""
    lines = [clean(line) for line in raw_text.splitlines()]
    lines = [line for line in lines if line]

    tin_idx = [
        i for i, line in enumerate(lines)
        if TIN_RE.search(line)
    ]

    if not tin_idx:
        return False

    first_tin = tin_idx[0]
    last_tin = first_tin

    for i in tin_idx[1:]:
        if i == last_tin + 1:
            last_tin = i
        else:
            break

    amt_idx = next(
        (
            i for i in range(last_tin + 1, len(lines))
            if re.fullmatch(r"\$?\s*[\d,]+\.\d{2}", lines[i])
        ),
        None,
    )

    if amt_idx is None:
        return False

    payer = lines[:first_tin]
    recip = lines[last_tin + 1:amt_idx]

    label_like = re.compile(
        r"payer|recipient|street address|\btin\b",
        re.I,
    )

    if any(
        label_like.search(line) or _is_boilerplate(line)
        for line in payer + recip
    ):
        return False

    if payer:
        name, addr, phone = _split_payer(payer)

        result["payer_name"] = name
        result["payer_address"] = addr
        result["payer_phone"] = phone

    if recip:
        # Only accept a valid name. Do not assume the first value is a name.
        for line in recip:
            candidate = _valid_recipient_name(line)

            if candidate:
                result["recipient_name"] = candidate
                break

        if len(recip) > 1:
            result["recipient_address"] = ", ".join(recip[1:])

    return True


def _extract_payer(left_text, result):
    m = re.search(
        r"PAYER[''`]?[Ss]?\s+name[^\n]*\n(.*?)"
        r"(?=PAYER[''`]?[Ss]?\s+TIN|RECIPIENT[''`]?[Ss]?\s+name|$)",
        left_text,
        re.I | re.S,
    )

    if not m:
        return

    parts = []

    for line in m.group(1).splitlines():
        c = clean(line)

        if c and not _is_boilerplate(c):
            parts.append(c)

    if parts:
        name, addr, phone = _split_payer(parts)

        result["payer_name"] = name
        result["payer_address"] = addr
        result["payer_phone"] = phone


def _extract_recipient(left_text, result):
    """Extract recipient name/address without amount or heading leakage."""
    label_pattern = re.compile(
        r"recipient\s*['’`]?\s*s?\s+name\b",
        re.IGNORECASE,
    )

    lines = [clean(line) for line in left_text.splitlines()]
    lines = [line for line in lines if line]

    for index, line in enumerate(lines):
        match = label_pattern.search(line)

        if not match:
            continue

        # Case 1: value after the label on the same line.
        candidate = _valid_recipient_name(line[match.end():])

        if candidate:
            result["recipient_name"] = candidate
            break

        # Case 2: value appears on a following line.
        for next_line in lines[index + 1:index + 5]:
            candidate = _valid_recipient_name(next_line)

            if candidate:
                result["recipient_name"] = candidate
                break

        if result.get("recipient_name"):
            break

        # Case 3: value appears immediately before the label.
        for previous_line in reversed(lines[max(0, index - 4):index]):
            candidate = _valid_recipient_name(previous_line)

            if candidate:
                result["recipient_name"] = candidate
                break

        if result.get("recipient_name"):
            break

    # Recipient address.
    address_pattern = re.compile(
        r"RECIPIENT['’`]?\s*S?\s+address[^\n]*\n(.*?)"
        r"(?=Account number|FATCA|Form 1099|Instructions|"
        r"PAYER['’`]?\s*S?|$)",
        re.IGNORECASE | re.DOTALL,
    )

    match = address_pattern.search(left_text)

    if match:
        address_parts = []

        for line in match.group(1).splitlines():
            value = clean(line)

            if not value or _is_boilerplate(value):
                continue

            if re.search(
                r"city or town|state or province|foreign postal code",
                value,
                re.IGNORECASE,
            ):
                continue

            address_parts.append(value)

        if address_parts:
            result["recipient_address"] = ", ".join(address_parts)


def _normalize_payer_fields(result):
    """Recover payer name/address when text fallback mixes form labels."""
    name = clean(result.get("payer_name"))
    address = clean(result.get("payer_address"))
    combined = " ".join(filter(None, [name, address]))

    combined = re.sub(
        r"\$\s*[\d,]+(?:\.\d{2})?",
        "",
        combined,
    )

    combined = re.sub(
        r"\b(?:Form\s+)?1099\s*-\s*NEC\b",
        "",
        combined,
        flags=re.IGNORECASE,
    )

    combined = re.sub(
        r"\bNonemployee\s+Compensation\b",
        "",
        combined,
        flags=re.IGNORECASE,
    )

    combined = re.sub(
        r"\bFor\s+Rec(?:i|ie)pient\b",
        "",
        combined,
        flags=re.IGNORECASE,
    )

    match = re.search(
        r"\bP\.?\s*O\.?\s*Box\s+\d+[A-Za-z0-9-]*",
        combined,
        re.IGNORECASE,
    )

    if match:
        payer_name = clean(combined[:match.start()])
        remaining = clean(combined[match.start():])

        payer_name = re.sub(r"\b20\d{2}\b", "", payer_name or "")
        payer_name = clean(payer_name)

        remaining = re.sub(
            r"\b(?:Nonemployee\s+)?Compensation\b",
            "",
            remaining or "",
            flags=re.IGNORECASE,
        )

        remaining = re.sub(r"\s*,\s*", ", ", remaining)
        remaining = re.sub(r"\s+", " ", remaining)
        remaining = re.sub(r",\s*,", ",", remaining)
        remaining = remaining.strip(" ,")
        remaining = clean(remaining)

        if payer_name:
            result["payer_name"] = payer_name

        if remaining:
            result["payer_address"] = remaining

    return result


def _normalize_recipient_address(result):
    """Keep the recipient street address, excluding city/state and form text."""
    address = clean(result.get("recipient_address"))

    if not address:
        return result

    match = re.search(
        r"\b\d+\s+[A-Z0-9]+(?:[ \t]+[A-Z0-9]+)*[ \t]+"
        r"(?:ROAD|RD|STREET|ST|AVENUE|AVE|DRIVE|DR|LANE|LN|"
        r"BOULEVARD|BLVD|COURT|CT|PLACE|PL|WAY|PARKWAY|PKWY)\b",
        address,
        re.IGNORECASE,
    )

    if match:
        result["recipient_address"] = clean(match.group(0))

    return result


def _has_sanity_issue(result):
    combined = " ".join(
        str(result.get(k) or "")
        for k in (
            "payer_name",
            "payer_address",
            "payer_phone",
            "recipient_name",
            "recipient_address",
        )
    ).lower()

    return any(marker in combined for marker in _SANITY_MARKERS)


# ─────────────────────── main entry ───────────────────────

def parse_1099_nec_2025(pages, filename=None, pdf_bytes=None):
    pages = pages or []

    full_text = "\n".join(
        (p.get("text") or "")
        for p in pages
        if p
    )

    raw_text = full_text

    if pdf_bytes and pdf_bytes[:4] == b"%PDF":
        try:
            doc = fitz.open(stream=pdf_bytes, filetype="pdf")

            try:
                raw_text = "\n".join(
                    page.get_text("text", sort=True) or ""
                    for page in doc
                )
            finally:
                doc.close()

        except Exception:
            pass

    result = {key: None for key in ALL_FIELDS}
    warnings = []
    used = []

    # 1. Layout parser.
    for words, w, h in _iter_word_sources(pages, pdf_bytes, warnings):
        found = _parse_layout(words, w, h)

        if found:
            _fill_missing(result, found)
            result["box2_direct_sales"] = found["box2_direct_sales"]
            used.append("layout")
            break

    # 2. Value-block fallback.
    needs_text = any(
        result[key] is None
        for key in (
            "payer_name",
            "recipient_name",
            "payer_address",
            "recipient_address",
        )
    )

    if needs_text:
        tmp = {key: None for key in ALL_FIELDS}

        if _extract_value_block(raw_text, tmp):
            _fill_missing(result, tmp)
            used.append("value_block")

    # 3. Label-based fallback.
    if any(
        result[key] is None
        for key in (
            "payer_name",
            "recipient_name",
            "payer_address",
            "recipient_address",
        )
    ):
        tmp = {key: None for key in ALL_FIELDS}

        _extract_payer(raw_text, tmp)
        _extract_recipient(raw_text, tmp)

        if any(tmp.values()):
            _fill_missing(result, tmp)
            used.append("label_based")

    # 4. Plain-text scalar fallbacks.
    if result["tax_year"] is None:
        m = (
            re.search(r"1099-NEC\s*(20\d{2})", raw_text, re.I)
            or re.search(r"\b(20\d{2})\b", raw_text)
        )

        if m:
            result["tax_year"] = int(m.group(1))

    if result["payer_tin"] is None or result["recipient_tin"] is None:
        tins = TIN_RE.findall(raw_text)

        if result["payer_tin"] is None and tins:
            result["payer_tin"] = tins[0]

        if result["recipient_tin"] is None and len(tins) >= 2:
            result["recipient_tin"] = tins[1]

    if result["box1_nonemployee_compensation"] is None:
        m = re.search(
            r"1\s*Nonemploy[a-z]*.*?\$\s*([\d,]+\.\d{2})",
            raw_text,
            re.I | re.S,
        )

        if not m:
            m = re.search(
                r"^\s*\$?\s*([\d,]+\.\d{2})\s*$",
                raw_text,
                re.M,
            )

        if m:
            result["box1_nonemployee_compensation"] = _parse_amount(
                m.group(1)
            )

    if result["box2_direct_sales"] is None:
        result["box2_direct_sales"] = False

    # Box 3 is reserved on the 2025 form.

    if not used:
        warnings.append(
            "Layout parsing found nothing; used raw text only."
        )
    elif used == ["value_block"] or used == ["label_based"]:
        warnings.append(
            "Layout parsing unavailable; used text fallback."
        )

    # Normalize payer and recipient address.
    _normalize_payer_fields(result)
    _normalize_recipient_address(result)

    # 4.1 Final recipient-name recovery.
    # IMPORTANT: validate any existing value first. An earlier parser
    # strategy may already have incorrectly populated recipient_name.
    existing_name = _valid_recipient_name(result.get("recipient_name"))
    result["recipient_name"] = existing_name

    lines = [clean(line) for line in raw_text.splitlines()]
    lines = [line for line in lines if line]

    label_pattern = re.compile(
        r"recipient\s*['’`]?\s*s?\s+name\b",
        re.IGNORECASE,
    )

    if not result.get("recipient_name"):
        for index, line in enumerate(lines):
            match = label_pattern.search(line)

            if not match:
                continue

            # Case A: label and value share a line.
            after_label = line[match.end():]
            after_label = re.sub(r"^[\s:：\-]+", "", after_label)

            candidate = _valid_recipient_name(after_label)

            if candidate:
                result["recipient_name"] = candidate
                break

            # Case B: the name appears on one of the next few lines.
            for next_line in lines[index + 1:index + 5]:
                candidate = _valid_recipient_name(next_line)

                if candidate:
                    result["recipient_name"] = candidate
                    break

            if result.get("recipient_name"):
                break

            # Case C: the name appears before the printed label.
            for previous_line in reversed(
                lines[max(0, index - 5):index]
            ):
                candidate = _valid_recipient_name(previous_line)

                if candidate:
                    result["recipient_name"] = candidate
                    break

            if result.get("recipient_name"):
                break
                

    
    # 4.2 Recover recipient name using the PDF's actual word coordinates.
    if not result.get("recipient_name") and pdf_bytes:
        recovered_name = _recover_recipient_name_from_pdf(pdf_bytes)

        if recovered_name:
            result["recipient_name"] = recovered_name

    # 5. Validation.

    for key, label in (
        ("payer_tin", "Payer TIN"),
        ("recipient_tin", "Recipient TIN"),
        ("payer_name", "Payer name"),
        ("payer_address", "Payer address"),
        ("recipient_name", "Recipient name"),
        ("recipient_address", "Recipient address"),
    ):
        if not result[key]:
            warnings.append(f"{label} could not be read.")

    if result["box1_nonemployee_compensation"] is None:
        warnings.append(
            "Box 1 Nonemployee compensation could not be read."
        )

    if _has_sanity_issue(result):
        warnings.append(
            "Label text may have leaked into a value; please verify manually."
        )

    result["source_page_count"] = len(pages)
    result["parse_warnings"] = warnings
    result["review_required"] = bool(warnings)
    result["form_type"] = "1099_nec_2025"

    if filename:
        result["filename"] = filename

    return result
