# _1099_misc_2025_parser.py
"""
1099-MISC (2025 revision) parser.

Consumes page dictionaries produced by extract_pages() in app.py:
    {page, text, text_alt, width, height, source, image}

Returns a flat dictionary compatible with FIELDS_1099_MISC_2025.
Only page 1 is used.
"""

from __future__ import annotations

import re
from typing import Any


# ---------------------------------------------------------------------
# Regex helpers
# ---------------------------------------------------------------------

_EIN_RE = re.compile(r"(?<!\d)\d{2}[-\s]?\d{7}(?!\d)")
_SSN_RE = re.compile(r"(?<!\d)\d{3}[-\s]?\d{2}[-\s]?\d{4}(?!\d)")

# Accept integers, decimals, comma-separated amounts, and optional $.
_MONEY_RE = re.compile(
    r"(?<![\w./-])\$?\s*"
    r"(?P<amount>(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?)"
    r"(?![\w./-])"
)

# NEW: A bare number followed by one of these words is a BOX NUMBER,
# not a monetary value. Used to skip "6 Medical...", "10 Gross...", etc.
_BOX_NUMBER_FOLLOW_RE = re.compile(
    r"^\s*(?:rents|royalties|other\s+income|federal\s+income\s+tax|"
    r"fed\.?\s+income\s+tax|fishing\s+boat|medical\s+and\s+health|"
    r"payer\s+made\s+direct|substitute\s+payments|crop\s+insurance|"
    r"gross\s+proceeds|fish\s+purchased|section\s+409a|FATCA|"
    r"excess\s+golden|nonqualified\s+deferred|state\s+tax|state\s+income)",
    re.IGNORECASE,
)

_BOX_ANCHORS = {
    "box1_rents": [
        r"\b1\s*[.\-:]?\s*Rents\b",
    ],
    "box2_royalties": [
        r"\b2\s*[.\-:]?\s*Royalties\b",
    ],
    "box3_other_income": [
        r"\b3\s*[.\-:]?\s*Other\s+income\b",
    ],
    "box4_fed_tax_withheld": [
        r"\b4\s*[.\-:]?\s*(?:Federal|Fed\.?)\s+income\s+tax\s+withheld\b",
    ],
    "box5_fishing_boat_proceeds": [
        r"\b5\s*[.\-:]?\s*Fishing\s+boat\s+proceeds\b",
    ],
    "box6_medical_healthcare": [
        r"\b6\s*[.\-:]?\s*Medical\s+and\s+health\s+care\s+payments\b",
    ],
    "box7_direct_sales": [
        r"\b7\s*[.\-:]?\s*Payer\s+made\s+direct\s+sales\b",
    ],
    "box8_substitute_payments": [
        r"\b8\s*[.\-:]?\s*Substitute\s+payments\b",
    ],
    "box9_crop_insurance": [
        r"\b9\s*[.\-:]?\s*Crop\s+insurance\s+proceeds\b",
    ],
    "box10_gross_proceeds_attorney": [
        r"\b10\s*[.\-:]?\s*Gross\s+proceeds\s+paid\s+to\s+an\s+attorney\b",
    ],
    "box11_fish_purchased_resale": [
        r"\b11\s*[.\-:]?\s*Fish\s+purchased\s+for\s+resale\b",
    ],
    "box12_section_409a": [
        r"\b12\s*[.\-:]?\s*Section\s+409A\s+deferrals\b",
    ],
    "box13_fatca": [
        r"\b13\s*[.\-:]?\s*FATCA\s+filing\s+requirement\b",
    ],
    "box14_excess_golden": [
        r"\b14\s*[.\-:]?\s*Excess\s+golden\s+parachute\b",
    ],
    "box15_nonqualified_deferred": [
        r"\b15\s*[.\-:]?\s*Nonqualified\s+deferred\s+compensation\b",
    ],
    "box16_state_tax_withheld": [
        r"\b16\s*[.\-:]?\s*State\s+tax\s+withheld\b",
    ],
    "box17_state_payer_no": [
        r"\b17\s*[.\-:]?\s*State\s*/\s*Payer'?s?\s+state\s+no\.?\b",
    ],
    "box18_state_income": [
        r"\b18\s*[.\-:]?\s*State\s+income\b",
    ],
}

_BOX_REGEXES = {
    key: [re.compile(pattern, re.IGNORECASE) for pattern in patterns]
    for key, patterns in _BOX_ANCHORS.items()
}

# Box labels that indicate another field has started.
_ANY_BOX_LABEL_RE = re.compile(
    r"\b(?:box\s*)?(?:[1-9]|1[0-8])\s*[.\-:]?\s*"
    r"(?:rents|royalties|other\s+income|federal\s+income\s+tax|"
    r"fed\.?\s+income\s+tax|fishing\s+boat|medical\s+and\s+health|"
    r"payer\s+made\s+direct|substitute\s+payments|crop\s+insurance|"
    r"gross\s+proceeds|fish\s+purchased|section\s+409a|FATCA|"
    r"excess\s+golden|nonqualified\s+deferred|state\s+tax|state\s+income)",
    re.IGNORECASE,
)


# ---------------------------------------------------------------------
# Text utilities
# ---------------------------------------------------------------------

def _normalise_text(value: str) -> str:
    """Normalize OCR text without deleting meaningful line boundaries."""
    value = (value or "").replace("\x00", " ")
    value = value.replace("\u00a0", " ")
    value = value.replace("\r\n", "\n").replace("\r", "\n")
    value = re.sub(r"[ \t]+", " ", value)
    value = re.sub(r"\n[ \t]+", "\n", value)
    value = re.sub(r"\n{3,}", "\n\n", value)
    return value.strip()


def _clean_line(value: str) -> str:
    """Remove common OCR noise from a candidate field line."""
    value = re.sub(r"\s+", " ", value or "").strip()
    value = value.strip(" |:;,_")
    value = re.sub(r"^[•*]+", "", value).strip()
    return value


def _join_first_page_text(pages: list[dict]) -> str:
    """
    Use page 1 only.

    Prefer whichever OCR/text extraction contains more useful text.
    """
    if not pages:
        return ""

    first_page = pages[0] or {}

    primary = _normalise_text(first_page.get("text") or "")
    alternate = _normalise_text(first_page.get("text_alt") or "")

    if not primary:
        return alternate

    if not alternate:
        return primary

    if len(alternate) > len(primary) * 1.20:
        return alternate

    return primary


def _clean_amount(raw: str) -> float | None:
    if not raw:
        return None

    raw = raw.replace("$", "").replace(",", "").strip()

    try:
        amount = float(raw)
    except (ValueError, TypeError):
        return None

    return int(amount) if amount.is_integer() else amount


def _extract_tax_year(text: str) -> int | None:
    patterns = [
        r"(?:tax\s+year|calendar\s+year|for\s+the\s+year)\s*[:\-]?\s*(20\d{2})",
        r"FORM\s+1099[- ]?MISC.{0,80}?\b(20\d{2})\b",
        r"\b(20\d{2})\s+FORM\s+1099[- ]?MISC\b",
    ]

    for pattern in patterns:
        match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
        if match:
            year = int(match.group(1))
            if 2000 <= year <= 2100:
                return year

    return None

# ---------------------------------------------------------------------
# TIN extraction
# ---------------------------------------------------------------------

# Accepts dashes, spaces, and dots as separators (OCR sometimes produces dots).
_TIN_TOKEN = (
    r"(?:\d{3}[-\s.]?\d{2}[-\s.]?\d{4}"   # SSN: XXX-XX-XXXX
    r"|\d{2}[-\s.]?\d{7})"                 # EIN: XX-XXXXXXX
)

_TIN_RE = re.compile(rf"(?<!\d){_TIN_TOKEN}(?!\d)")


def _normalise_tin(value: str) -> str:
    """
    Normalize a TIN to a display format while preserving SSN vs EIN shape.
    Falls back to EIN format for unseparated 9-digit strings.
    """
    if not value:
        return ""

    original = value.strip()
    digits = re.sub(r"\D", "", original)

    if len(digits) != 9:
        return original

    # Preserve SSN format: XXX-XX-XXXX
    if re.match(r"^\d{3}[\-\s.]\d{2}[\-\s.]\d{4}$", original):
        return f"{digits[:3]}-{digits[3:5]}-{digits[5:]}"

    # Preserve EIN format: XX-XXXXXXX
    if re.match(r"^\d{2}[\-\s.]\d{7}$", original):
        return f"{digits[:2]}-{digits[2:]}"

    # No separator – default to EIN format.
    return f"{digits[:2]}-{digits[2:]}"


def _tin_digits(value: str) -> str:
    """Return only the digits of a TIN, for equality comparisons."""
    return re.sub(r"\D", "", value or "")


def _extract_tins(text: str) -> tuple[str, str]:
    """
    Extract payer and recipient TINs from page 1 text.

    Priority:
      1. Explicit PAYER'S TIN / RECIPIENT'S TIN labels (spans newlines).
      2. Looser party-name proximity (recipient/payee/payer).
      3. Global fallback: first unique TIN = payer, second unique = recipient.
    """
    payer_tin = ""
    recipient_tin = ""

    # ---- Payer TIN --------------------------------------------------
    payer_patterns = [
        r"PAYER'?S?\s+(?:TIN|taxpayer\s+identification\s+number)"
        rf"[\s\S]{{0,100}}?({_TIN_TOKEN})",
        r"\bpayer\b[^\n]{0,120}?" rf"({_TIN_TOKEN})",
    ]

    for pattern in payer_patterns:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            candidate = _normalise_tin(match.group(1))
            if candidate:
                payer_tin = candidate
                break

    # ---- Recipient TIN ----------------------------------------------
    payer_digits = _tin_digits(payer_tin)

    recipient_patterns = [
        r"RECIPIENT'?S?\s+(?:TIN|taxpayer\s+identification\s+number)"
        rf"[\s\S]{{0,100}}?({_TIN_TOKEN})",
        r"RECIPIENT\s+(?:TIN|taxpayer\s+identification\s+number)"
        rf"[\s\S]{{0,100}}?({_TIN_TOKEN})",
        r"\b(?:recipient|payee)\b[^\n]{0,120}?" rf"({_TIN_TOKEN})",
    ]

    for pattern in recipient_patterns:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            candidate = _normalise_tin(match.group(1))
            if candidate and _tin_digits(candidate) != payer_digits:
                recipient_tin = candidate
                break

    # ---- Global fallback --------------------------------------------
    all_tins: list[str] = []
    all_tin_digits: set[str] = set()

    for match in _TIN_RE.finditer(text):
        candidate = _normalise_tin(match.group(0))
        digits = _tin_digits(candidate)
        if candidate and digits not in all_tin_digits:
            all_tins.append(candidate)
            all_tin_digits.add(digits)

    if not payer_tin and all_tins:
        payer_tin = all_tins[0]

    if not recipient_tin:
        for candidate in all_tins:
            if _tin_digits(candidate) != _tin_digits(payer_tin):
                recipient_tin = candidate
                break

    return payer_tin, recipient_tin


# ---------------------------------------------------------------------
# Noise helpers
# ---------------------------------------------------------------------

_NOISE_RE = re.compile(
    r"^(?:"
    r"payer'?s?\s+(?:name|tin|federal)|"
    r"recipient'?s?\s+(?:name|tin|account)|"
    r"street\s+address|city\s+or\s+town|"
    r"state\s+or\s+province|foreign\s+postal|"
    r"account\s+number|2nd\s+tIN|"
    r"form\s+1099|copy\s+[a-z]|"
    r"nonemployee|corrected|void|"
    r"department\s+of\s+the\s+treasury|"
    r"internal\s+revenue\s+service|"
    r"for\s+calendar\s+year|"
    r"OMB\s+No\.?"
    r")\b",
    re.IGNORECASE,
)


def _is_noise_line(line: str) -> bool:
    if not line:
        return True

    if _NOISE_RE.search(line):
        return True

    if len(line) > 120:
        return True

    if re.fullmatch(r"[\W\d_]+", line):
        return True

    return False


def _lines(text: str) -> list[str]:
    return [_clean_line(line) for line in text.splitlines() if _clean_line(line)]


def _extract_label_section(
    text: str,
    label_pattern: str,
    stop_patterns: list[str] | None = None,
) -> list[str]:
    """
    Return lines after a field label, stopping at another field label.
    """
    lines = _lines(text)
    label_rx = re.compile(label_pattern, re.IGNORECASE)
    stops = [
        re.compile(pattern, re.IGNORECASE)
        for pattern in (stop_patterns or [])
    ]

    for index, line in enumerate(lines):
        match = label_rx.search(line)
        if not match:
            continue

        output = []

        remainder = _clean_line(line[match.end():])
        remainder = re.sub(r"^[\s:|.-]+", "", remainder)

        if remainder and not _is_noise_line(remainder):
            output.append(remainder)

        for next_line in lines[index + 1:index + 9]:
            if any(rx.search(next_line) for rx in stops):
                break

            if re.search(
                r"\b(?:payer'?s?|recipient'?s?)\s+"
                r"(?:name|TIN|federal|street|city|state|ZIP|"
                r"account|telephone)\b",
                next_line,
                re.IGNORECASE,
            ):
                break

            if _is_noise_line(next_line):
                continue

            output.append(next_line)

            if len(output) >= 4:
                break

        if output:
            return output

    return []


# ---------------------------------------------------------------------
# NEW: Clean name extraction
# ---------------------------------------------------------------------

# Words that appear on the form and should never make up a recipient/payer name.
_FORM_WORD_RE = re.compile(
    r"\b(?:rents|royalties|income|federal|tax|withheld|fishing|"
    r"boat|proceeds|medical|health|care|payments|payer|payee|"
    r"made|direct|sales|substitute|crop|insurance|gross|paid|"
    r"attorney|fish|purchased|resale|section|deferrals|409a|"
    r"fatca|filing|requirement|excess|golden|parachute|"
    r"nonqualified|deferred|compensation|state|recipient|"
    r"account|consumer|products|furnished|required|file|"
    r"negligence|return|form|copy|department|treasury|internal|"
    r"revenue|service|calendar|year|corrected|void|nonemployee|"
    r"name|address|street|city|town|province|postal|telephone|"
    r"number|box|irs|omb|description|amount|and|or)\b",
    re.IGNORECASE,
)

_ADDRESS_SUFFIXES = {
    "st", "street", "ave", "avenue", "rd", "road", "ct", "court",
    "dr", "drive", "ln", "lane", "blvd", "boulevard", "way", "pl",
    "place", "ter", "terrace", "pkwy", "parkway", "cir", "circle",
    "hwy", "highway", "sq", "square",
}

_NAME_CAPS_RE = re.compile(
    r"\b[A-Z][A-Z'.\-]{1,}(?:\s+[A-Z][A-Z'.\-]{1,}){1,4}\b"
)

_NAME_TITLE_RE = re.compile(
    r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4}\b"
)


def _extract_clean_name(value: str) -> str:
    """Pull a clean person or business name out of a possibly noisy line."""
    if not value:
        return ""

    for pattern in (_NAME_CAPS_RE, _NAME_TITLE_RE):
        for match in pattern.finditer(value):
            candidate = match.group(0).strip()
            if not candidate:
                continue

            words = candidate.split()
            if not (2 <= len(words) <= 5):
                continue

            # Reject candidates made entirely of form words.
            if _FORM_WORD_RE.fullmatch(candidate):
                continue

            # Reject candidates that end with an address suffix
            # (e.g. "Walter Ct" is an address line, not a name).
            last_word = re.sub(r"[^A-Za-z]", "", words[-1]).lower()
            if last_word in _ADDRESS_SUFFIXES:
                continue

            # Reject if any word is a form word.
            if any(_FORM_WORD_RE.fullmatch(w) for w in words):
                continue

            # Reject if candidate contains obvious form phrases.
            if re.search(
                r"\band\s+(?:address|name|tin)\b",
                candidate,
                re.IGNORECASE,
            ):
                continue

            return candidate

    return ""


def _extract_name(text: str, party: str) -> str:
    print(f"\nDEBUG: party = {party}")

    if party == "payer":
        label = r"PAYER'?S?\s+(?:name\s+and\s+address|name)"
        fallback_label = r"PAYER'?S?"
    else:
        label = r"RECIPIENT'?S?\s+(?:name\s+and\s+address|name)"
        fallback_label = r"RECIPIENT'?S?"

    print(f"DEBUG: label = {label}")

    # First try the existing label-section extraction.
    candidates = _extract_label_section(
        text,
        label,
        [
            r"RECIPIENT'?S?\s+TIN",
            r"PAYER'?S?\s+TIN",
            r"account\s+number",
            r"2nd\s+TIN",
            r"\b1\s*[.\-:]?\s*Rents\b",
        ],
    )

    print(f"DEBUG: candidates = {candidates}")

    for candidate in candidates:
        candidate = _clean_line(candidate)
        name = _extract_clean_name(candidate)

        print(f"DEBUG: candidate = {candidate!r}")
        print(f"DEBUG: extracted name = {name!r}")

        if name:
            return name[:100]

    # Fallback: inspect OCR lines around the payer/recipient label.
    lines = _lines(text)

    for index, line in enumerate(lines):
        if not re.search(fallback_label, line, re.IGNORECASE):
            continue

        print(f"DEBUG: fallback matched line {index}: {line!r}")

        # Include the matched line and the following lines.
        area_lines = lines[index:index + 8]

        for candidate in area_lines:
            print(f"DEBUG: fallback candidate = {candidate!r}")

            # Skip labels, TINs, and form instructions.
            if _is_noise_line(candidate):
                continue

            if re.search(
                r"PAYER'?S?\s+(?:TIN|taxpayer)|"
                r"RECIPIENT'?S?\s+(?:TIN|name)|"
                r"account\s+number|"
                r"street\s+address|city\s+or\s+town",
                candidate,
                re.IGNORECASE,
            ):
                continue

            if _EIN_RE.search(candidate) or _SSN_RE.search(candidate):
                continue

            if re.match(r"^\d+\s+", candidate):
                continue

            name = _extract_clean_name(candidate)

            # Preserve business names containing &, apostrophes or digits.
            if not name and party == "payer":
                cleaned = re.sub(
                    r"\s+", " ", candidate
                ).strip(" |:;,_")

                if (
                    re.search(r"[A-Za-z]", cleaned)
                    and not _ADDRESS_NOISE_RE.search(cleaned)
                    and not _FORM_WORD_RE.search(cleaned)
                    and len(cleaned) <= 100
                ):
                    name = cleaned

            print(f"DEBUG: fallback extracted name = {name!r}")

            if name:
                return name[:100]

    print(f"DEBUG: no name found for {party}")
    return ""


# ---------------------------------------------------------------------
# NEW: Address extraction with real US-address validation
# ---------------------------------------------------------------------

_STREET_RE = re.compile(
    r"\b\d+\s+[A-Za-z][A-Za-z.'\-]*"
    r"(?:\s+[A-Za-z][A-Za-z.'\-]*){0,4}"
    r"\s+(?:St|Street|Ave|Avenue|Rd|Road|Ct|Court|Dr|Drive|"
    r"Ln|Lane|Blvd|Boulevard|Way|Pl|Place|Ter|Terrace|Pkwy|"
    r"Parkway|Cir|Circle|Hwy|Highway|Sq|Square)\b\.?",
    re.IGNORECASE,
)

_CITY_STATE_ZIP_RE = re.compile(
    r"\b([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){0,3})\s+"
    r"([A-Z]{2})\s+(\d{5}(?:-\d{4})?)\b"
)

_ADDRESS_NOISE_RE = re.compile(
    r"consumer products|furnished to the IRS|required to file|"
    r"negligence|crop insurance|gross proceeds|"
    r"federal income tax|state tax|street address|"
    r"city or town|state or province|foreign postal|"
    r"telephone no|payer'?s?\s+tin|recipient'?s?\s+tin|"
    r"account number|2nd tin|"
    r"\b(?:box\s*)?(?:[1-9]|1[0-8])\s*[.\-:]?\s*"
    r"(?:rents|royalties|other\s+income)\b",
    re.IGNORECASE,
)


def _extract_us_address(value: str) -> str:
    """Return a clean US address from text, or '' if none found."""
    if not value:
        return ""

    # Look for city, state, ZIP first.
    for city_match in _CITY_STATE_ZIP_RE.finditer(value):
        city = city_match.group(1).strip()
        state = city_match.group(2)
        zipcode = city_match.group(3)

        before = value[:city_match.start()]
        street_matches = list(_STREET_RE.finditer(before))
        if street_matches:
            street = street_matches[-1].group(0).strip()
            return f"{street}, {city} {state} {zipcode}"

    # No city/state/ZIP – try a standalone street line.
    street_match = _STREET_RE.search(value)
    if street_match:
        return street_match.group(0).strip()

    return ""


def _extract_address(text: str, party: str) -> str:
    if party == "payer":
        label = r"PAYER'?S?\s+(?:name\s+and\s+address|name)"
        other_party = r"RECIPIENT'?S?\s+"
    else:
        label = r"RECIPIENT'?S?\s+(?:name\s+and\s+address|name)"
        other_party = r"PAYER'?S?\s+"

    lines = _lines(text)
    label_rx = re.compile(label, re.IGNORECASE)

    for index, line in enumerate(lines):
        match = label_rx.search(line)
        if not match:
            continue

        candidates: list[str] = []

        remainder = _clean_line(line[match.end():])
        remainder = re.sub(r"^[\s:|.\-]+", "", remainder)
        if remainder and not _is_noise_line(remainder):
            candidates.append(remainder)

        for next_line in lines[index + 1:index + 14]:
            if re.search(other_party, next_line, re.IGNORECASE):
                break

            if re.search(
                r"\b(?:payer'?s?|recipient'?s?)\s+"
                r"(?:TIN|name|account)\b",
                next_line,
                re.IGNORECASE,
            ):
                break

            if _is_noise_line(next_line):
                continue

            if _EIN_RE.fullmatch(next_line) or _SSN_RE.fullmatch(next_line):
                continue

            candidates.append(next_line)

        # Drop form-instruction lines.
        cleaned: list[str] = []
        for candidate in candidates:
            candidate = _clean_line(candidate)
            if not candidate:
                continue
            if _ADDRESS_NOISE_RE.search(candidate):
                continue
            cleaned.append(candidate)

        if not cleaned:
            continue

        # Prefer a well-formed US address.
        joined = "\n".join(cleaned)
        us_addr = _extract_us_address(joined)
        if us_addr:
            return us_addr[:200]

        # Fall back to lines that actually contain digits.
        addr_lines = [c for c in cleaned if re.search(r"\d", c)]
        if addr_lines:
            return ", ".join(addr_lines[:3])[:200]

        return ", ".join(cleaned[:3])[:200]

    return ""


def _extract_phone(text: str) -> str:
    patterns = [
        r"(?:payer'?s?\s+)?(?:telephone|phone)\s*(?:number|no\.?)?"
        r"[^0-9]{0,30}(\+?1?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4})",
        r"\b(\d{3}[-.\s]\d{3}[-.\s]\d{4})\b",
    ]

    for pattern in patterns:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            return match.group(1).strip()

    return ""


def _extract_state_payer_no(text: str) -> str:
    patterns = [
        r"17\s*[.\-:]?\s*State\s*/\s*Payer'?s?\s+state\s+no\.?"
        r"[^A-Z0-9]{0,20}([A-Z0-9-]{4,20})",
        r"State\s*/\s*Payer'?s?\s+state\s+no\.?"
        r"[^A-Z0-9]{0,20}([A-Z0-9-]{4,20})",
    ]

    for pattern in patterns:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            value = match.group(1).strip()
            if value.lower() not in {"income", "withheld", "number"}:
                return value

    return ""


# ---------------------------------------------------------------------
# NEW: Valid amount finder that skips box numbers
# ---------------------------------------------------------------------

def _find_valid_amount(snippet: str) -> float | None:
    """Find the first monetary amount that is not a box number or a year."""
    for match in _MONEY_RE.finditer(snippet):
        raw = match.group("amount")

        # Skip standalone 4-digit years.
        if re.fullmatch(r"20\d{2}", raw):
            continue

        # Skip box numbers: a number immediately followed by a box label
        # word (e.g. the "6" in "6 Medical and health care payments").
        after = snippet[match.end():match.end() + 60]
        if _BOX_NUMBER_FOLLOW_RE.match(after):
            continue

        amount = _clean_amount(raw)
        if amount is not None:
            return amount

    return None


# ---------------------------------------------------------------------
# Box value extraction
# ---------------------------------------------------------------------

def _find_box_value(text: str, key: str) -> float | None:
    """
    Find a box amount while skipping box numbers and years.

    The snippet after the label is truncated at the next box label, so
    the parser never reads a number from the following box.
    """
    for rx in _BOX_REGEXES.get(key, []):
        match = rx.search(text)
        if not match:
            continue

        snippet_end = min(len(text), match.end() + 250)
        snippet = text[match.end():snippet_end]

        # Truncate at the next box label.
        next_box = _ANY_BOX_LABEL_RE.search(snippet)
        if next_box:
            snippet = snippet[:next_box.start()]

        value = _find_valid_amount(snippet)
        if value is not None:
            return value

    return None


def _find_box_bool(text: str, key: str) -> bool:
    """Check whether a checkbox is marked, near the specific box label."""
    for rx in _BOX_REGEXES.get(key, []):
        match = rx.search(text)
        if not match:
            continue

        line_end = text.find("\n", match.end())
        if line_end == -1:
            line_end = min(len(text), match.end() + 100)

        snippet = text[match.end():line_end]

        # Truncate at the next box label.
        next_box = _ANY_BOX_LABEL_RE.search(snippet)
        if next_box:
            snippet = snippet[:next_box.start()]

        if re.search(
            r"(?:\[\s*[xX✓✔]\s*\]|\b[xX✓✔]\b|\bchecked\b|\byes\b|\btrue\b)",
            snippet,
            re.IGNORECASE,
        ):
            return True

    return False


# ---------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------

def parse_1099_misc_2025(
    pages: list[dict],
    filename: str = "",
) -> dict[str, Any]:
    """Parse a 2025 Form 1099-MISC using page 1 only."""

    text = _join_first_page_text(pages)

    if not text:
        raise ValueError(
            "The uploaded 1099-MISC file contained no readable text "
            "on page 1. If this is a scanned PDF, OCR must run in app.py."
        )

    warnings: list[str] = []

    payer_tin, recipient_tin = _extract_tins(text)
    payer_name = _extract_name(text, "payer")
    recipient_name = _extract_name(text, "recipient")
    payer_address = _extract_address(text, "payer")
    recipient_address = _extract_address(text, "recipient")

    if not payer_tin:
        warnings.append("Could not locate the payer's TIN.")

    if not recipient_tin:
        warnings.append("Could not locate the recipient's TIN.")

    if not payer_name:
        warnings.append("Could not confidently locate the payer's name.")

    if not recipient_name:
        warnings.append("Could not confidently locate the recipient's name.")

    money_fields = [
        "box1_rents",
        "box2_royalties",
        "box3_other_income",
        "box4_fed_tax_withheld",
        "box5_fishing_boat_proceeds",
        "box6_medical_healthcare",
        "box8_substitute_payments",
        "box9_crop_insurance",
        "box10_gross_proceeds_attorney",
        "box11_fish_purchased_resale",
        "box12_section_409a",
        "box14_excess_golden",
        "box15_nonqualified_deferred",
        "box16_state_tax_withheld",
        "box18_state_income",
    ]

    bool_fields = [
        "box7_direct_sales",
        "box13_fatca",
    ]

    result: dict[str, Any] = {
        "form_type": "1099_misc_2025",
        "filename": filename,
        "tax_year": _extract_tax_year(text) or 2025,
        "account_number": "",
        "payer_tin": payer_tin,
        "payer_name": payer_name,
        "payer_address": payer_address,
        "payer_phone": _extract_phone(text),
        "recipient_tin": recipient_tin,
        "recipient_name": recipient_name,
        "recipient_address": recipient_address,
        "box17_state_payer_no": _extract_state_payer_no(text),
    }

    for key in money_fields:
        result[key] = _find_box_value(text, key)

    for key in bool_fields:
        result[key] = _find_box_bool(text, key)

    if not any(result.get(key) is not None for key in money_fields):
        warnings.append(
            "No monetary box values were detected. The PDF may be scanned "
            "at low resolution or use a non-standard layout."
        )

    result["parse_warnings"] = warnings

    return result