# _1098_parser.py
"""
Form 1098 (Mortgage Interest Statement) parser.

Consumes page dictionaries from extract_pages() in app.py:
    {page, text, text_alt, width, height, source, image}

Returns a flat dict compatible with FIELDS_1098_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)")

_MONEY_RE = re.compile(
    r"(?<![\w./-])\$?\s*"
    r"(?P<amount>(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?)"
    r"(?![\w./-])"
)

_DATE_RE = re.compile(
    r"\b(\d{1,2}[./-]\d{1,2}[./-]\d{2,4}|\d{8})\b"
)

_BOX_NUMBER_FOLLOW_RE = re.compile(
    r"^\s*(?:mortgage\s+interest|outstanding\s+mortgage|"
    r"mortgage\s+origination|refund\s+of\s+overpaid|"
    r"mortgage\s+insurance|points\s+paid|"
    r"if\s+checked,?\s+address|address\s+or\s+description|"
    r"number\s+of\s+properties|other\b|mortgage\s+acquisition)",
    re.IGNORECASE,
)

_BOX_ANCHORS = {
    "box1_mortgage_interest": [
        r"\b1\s*[.\-:]?\s*Mortgage\s+interest\s+received\b",
    ],
    "box2_outstanding_principal": [
        r"\b2\s*[.\-:]?\s*Outstanding\s+mortgage\s+principal\b",
    ],
    "box3_origination_date": [
        r"\b3\s*[.\-:]?\s*Mortgage\s+origination\s+date\b",
    ],
    "box4_refund_overpaid_interest": [
        r"\b4\s*[.\-:]?\s*Refund\s+of\s+overpaid\s+interest\b",
    ],
    "box5_mortgage_insurance_premiums": [
        r"\b5\s*[.\-:]?\s*Mortgage\s+insurance\s+premiums\b",
    ],
    "box6_points_paid": [
        r"\b6\s*[.\-:]?\s*Points\s+paid\s+on\s+purchase\b",
    ],
    "box7_property_same_as_recipient": [
        r"\b7\s*[.\-:]?\s*If\s+checked,?\s+address\s+of\s+property\b",
    ],
    "box8_property_address": [
        r"\b8\s*[.\-:]?\s*Address\s+or\s+description\s+of\s+property\b",
    ],
    "box9_num_properties": [
        r"\b9\s*[.\-:]?\s*Number\s+of\s+properties\b",
    ],
    "box10_other": [
        r"\b10\s*[.\-:]?\s*Other\b",
    ],
    "box11_acquisition_date": [
        r"\b11\s*[.\-:]?\s*Mortgage\s+acquisition\s+date\b",
    ],
}

_BOX_REGEXES = {
    key: [re.compile(p, re.IGNORECASE) for p in patterns]
    for key, patterns in _BOX_ANCHORS.items()
}

_ANY_BOX_LABEL_RE = re.compile(
    r"\b(?:box\s*)?(?:[1-9]|1[01])\s*[.\-:]?\s*"
    r"(?:mortgage\s+interest|outstanding\s+mortgage|"
    r"mortgage\s+origination|refund\s+of\s+overpaid|"
    r"mortgage\s+insurance|points\s+paid|"
    r"if\s+checked,?\s+address|address\s+or\s+description|"
    r"number\s+of\s+properties|other\b|mortgage\s+acquisition)",
    re.IGNORECASE,
)


# ---------------------------------------------------------------------
# Text utilities
# ---------------------------------------------------------------------

def _normalise_text(value: str) -> str:
    value = (value or "").replace("\x00", " ").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:
    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:
    if not pages:
        return ""
    first = pages[0] or {}
    primary = _normalise_text(first.get("text") or "")
    alternate = _normalise_text(first.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+calendar\s+year|"
        r"for\s+the\s+year)\s*[:\-]?\s*(20\d{2})",
        r"FORM\s+1098\b.{0,80}?\b(20\d{2})\b",
        r"\b(20\d{2})\s+FORM\s+1098\b",
    ]
    for pattern in patterns:
        m = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
        if m:
            y = int(m.group(1))
            if 2000 <= y <= 2100:
                return y
    return None


# ---------------------------------------------------------------------
# TIN extraction (RECIPIENT/LENDER and PAYER/BORROWER)
# ---------------------------------------------------------------------

_TIN_TOKEN = (
    r"(?:\d{3}[-\s.]?\d{2}[-\s.]?\d{4}"
    r"|\d{2}[-\s.]?\d{7})"
)
_TIN_RE = re.compile(rf"(?<!\d){_TIN_TOKEN}(?!\d)")


def _normalise_tin(value: str) -> str:
    if not value:
        return ""
    original = value.strip()
    digits = re.sub(r"\D", "", original)
    if len(digits) != 9:
        return original
    if re.match(r"^\d{3}[\-\s.]\d{2}[\-\s.]\d{4}$", original):
        return f"{digits[:3]}-{digits[3:5]}-{digits[5:]}"
    if re.match(r"^\d{2}[\-\s.]\d{7}$", original):
        return f"{digits[:2]}-{digits[2:]}"
    return f"{digits[:2]}-{digits[2:]}"


def _tin_digits(value: str) -> str:
    return re.sub(r"\D", "", value or "")


def _extract_tins(text: str) -> tuple[str, str]:
    """Return (recipient_tin, payer_tin)."""
    recipient_tin = ""
    payer_tin = ""

    # Recipient (lender) TIN
    for pattern in [
        r"(?:RECIPIENT'?S?|LENDER'?S?)\s+(?:TIN|taxpayer\s+identification\s+number)"
        rf"[\s\S]{{0,100}}?({_TIN_TOKEN})",
        r"\b(?:recipient|lender)\b[^\n]{0,120}?" rf"({_TIN_TOKEN})",
    ]:
        m = re.search(pattern, text, re.IGNORECASE)
        if m:
            candidate = _normalise_tin(m.group(1))
            if candidate:
                recipient_tin = candidate
                break

    recipient_digits = _tin_digits(recipient_tin)

    # Payer (borrower) TIN
    for pattern in [
        r"(?:PAYER'?S?|BORROWER'?S?)\s+(?:TIN|taxpayer\s+identification\s+number)"
        rf"[\s\S]{{0,100}}?({_TIN_TOKEN})",
        r"\b(?:payer|borrower)\b[^\n]{0,120}?" rf"({_TIN_TOKEN})",
    ]:
        m = re.search(pattern, text, re.IGNORECASE)
        if m:
            candidate = _normalise_tin(m.group(1))
            if candidate and _tin_digits(candidate) != recipient_digits:
                payer_tin = candidate
                break

    # Global fallback
    all_tins: list[str] = []
    seen: set[str] = set()
    for m in _TIN_RE.finditer(text):
        candidate = _normalise_tin(m.group(0))
        digits = _tin_digits(candidate)
        if candidate and digits not in seen:
            all_tins.append(candidate)
            seen.add(digits)

    if not recipient_tin and all_tins:
        recipient_tin = all_tins[0]
    if not payer_tin:
        for candidate in all_tins:
            if _tin_digits(candidate) != _tin_digits(recipient_tin):
                payer_tin = candidate
                break

    return recipient_tin, payer_tin


# ---------------------------------------------------------------------
# Name extraction
# ---------------------------------------------------------------------

_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")

_FORM_WORD_RE = re.compile(
    r"\b(?:mortgage|interest|received|outstanding|principal|origination|"
    r"date|refund|overpaid|insurance|premiums|points|paid|purchase|"
    r"residence|checked|address|description|property|securing|number|"
    r"properties|other|acquisition|recipient|lender|payer|borrower|"
    r"name|tin|street|city|town|state|province|country|postal|"
    r"telephone|account|form|copy|department|treasury|internal|revenue|"
    r"service|calendar|year|OMB|and|or|of|the|to|for)\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",
}

_NOISE_RE = re.compile(
    r"^(?:"
    r"recipient'?s?\s+(?:name|tin)|"
    r"payer'?s?\s+(?:name|tin)|"
    r"street\s+address|city\s+or\s+town|"
    r"state\s+or\s+province|foreign\s+postal|"
    r"account\s+number|"
    r"form\s+1098|copy\s+[a-z]|"
    r"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(l) for l in text.splitlines() if _clean_line(l)]


def _extract_clean_name(value: str) -> str:
    if not value:
        return ""
    for pattern in (_NAME_CAPS_RE, _NAME_TITLE_RE):
        for m in pattern.finditer(value):
            candidate = m.group(0).strip()
            if not candidate:
                continue
            words = candidate.split()
            if not (2 <= len(words) <= 5):
                continue
            if _FORM_WORD_RE.fullmatch(candidate):
                continue
            last = re.sub(r"[^A-Za-z]", "", words[-1]).lower()
            if last in _ADDRESS_SUFFIXES:
                continue
            if any(_FORM_WORD_RE.fullmatch(w) for w in words):
                continue
            return candidate
    return ""


def _extract_name(text: str, party: str) -> str:
    if party == "recipient":
        fallback = r"(?:RECIPIENT'?S?|LENDER'?S?)"
    else:
        fallback = r"(?:PAYER'?S?|BORROWER'?S?)"

    lines = _lines(text)

    for index, line in enumerate(lines):
        if not re.search(fallback, line, re.IGNORECASE):
            continue
        for candidate in lines[index:index + 8]:
            if _is_noise_line(candidate):
                continue
            if _EIN_RE.search(candidate) or _SSN_RE.search(candidate):
                continue
            name = _extract_clean_name(candidate)
            if name:
                return name[:100]
    return ""


# ---------------------------------------------------------------------
# Address extraction
# ---------------------------------------------------------------------

_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"
)


def _extract_us_address(value: str) -> str:
    if not value:
        return ""
    for city_m in _CITY_STATE_ZIP_RE.finditer(value):
        city = city_m.group(1).strip()
        state = city_m.group(2)
        zipcode = city_m.group(3)
        before = value[:city_m.start()]
        street_matches = list(_STREET_RE.finditer(before))
        if street_matches:
            street = street_matches[-1].group(0).strip()
            return f"{street}, {city} {state} {zipcode}"
    m = _STREET_RE.search(value)
    if m:
        return m.group(0).strip()
    return ""


def _extract_address(text: str, party: str) -> str:
    if party == "recipient":
        label = r"(?:RECIPIENT'?S?|LENDER'?S?)\s+(?:name\s+and\s+address|name)"
        other = r"(?:PAYER'?S?|BORROWER'?S?)\s+"
    else:
        label = r"(?:PAYER'?S?|BORROWER'?S?)\s+(?:name\s+and\s+address|name)"
        other = r"(?:RECIPIENT'?S?|LENDER'?S?)\s+"

    lines = _lines(text)
    label_rx = re.compile(label, re.IGNORECASE)

    for index, line in enumerate(lines):
        m = label_rx.search(line)
        if not m:
            continue

        candidates: list[str] = []
        remainder = _clean_line(line[m.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, 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)

        if not candidates:
            continue

        joined = "\n".join(candidates)
        us_addr = _extract_us_address(joined)
        if us_addr:
            return us_addr[:200]

        addr_lines = [c for c in candidates if re.search(r"\d", c)]
        if addr_lines:
            return ", ".join(addr_lines[:3])[:200]

        return ", ".join(candidates[:3])[:200]

    return ""


# ---------------------------------------------------------------------
# Phone
# ---------------------------------------------------------------------

def _extract_phone(text: str) -> str:
    patterns = [
        r"(?:recipient'?s?|lender'?s?|payer'?s?|borrower'?s?)?\s*"
        r"(?:telephone|phone)\s*(?:number|no\.?)?[^0-9]{0,30}"
        r"(\+?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:
        m = re.search(pattern, text, re.IGNORECASE)
        if m:
            return m.group(1).strip()
    return ""


# ---------------------------------------------------------------------
# Amount / date / text box lookups
# ---------------------------------------------------------------------

def _find_valid_amount(snippet: str) -> float | None:
    for m in _MONEY_RE.finditer(snippet):
        raw = m.group("amount")
        if re.fullmatch(r"20\d{2}", raw):
            continue
        after = snippet[m.end():m.end() + 60]
        if _BOX_NUMBER_FOLLOW_RE.match(after):
            continue
        amount = _clean_amount(raw)
        if amount is not None:
            return amount
    return None


def _find_box_value(text: str, key: str) -> float | None:
    for rx in _BOX_REGEXES.get(key, []):
        m = rx.search(text)
        if not m:
            continue

        # Same-line snippet
        snippet_end = min(len(text), m.end() + 250)
        snippet = text[m.end():snippet_end]
        nxt = _ANY_BOX_LABEL_RE.search(snippet)
        if nxt:
            snippet = snippet[:nxt.start()]
        value = _find_valid_amount(snippet)
        if value is not None:
            return value

        # Next-line fallback
        line_end = text.find("\n", m.end())
        if line_end == -1:
            continue
        next_line_end = text.find("\n", line_end + 1)
        if next_line_end == -1:
            next_line_end = len(text)
        next_line = text[line_end + 1:next_line_end]
        if _ANY_BOX_LABEL_RE.search(next_line):
            continue
        value = _find_valid_amount(next_line)
        if value is not None:
            return value

    return None


def _find_box_date(text: str, key: str) -> str:
    for rx in _BOX_REGEXES.get(key, []):
        m = rx.search(text)
        if not m:
            continue
        snippet_end = min(len(text), m.end() + 80)
        snippet = text[m.end():snippet_end]
        nxt = _ANY_BOX_LABEL_RE.search(snippet)
        if nxt:
            snippet = snippet[:nxt.start()]
        dm = _DATE_RE.search(snippet)
        if dm:
            return dm.group(1)
        # Next-line fallback
        line_end = text.find("\n", m.end())
        if line_end == -1:
            continue
        next_line_end = text.find("\n", line_end + 1)
        if next_line_end == -1:
            next_line_end = len(text)
        next_line = text[line_end + 1:next_line_end]
        if _ANY_BOX_LABEL_RE.search(next_line):
            continue
        dm = _DATE_RE.search(next_line)
        if dm:
            return dm.group(1)
    return ""


def _find_box_text(text: str, key: str, max_chars: int = 200) -> str:
    """Multi-line text field (box 8 property address, box 10 other)."""
    for rx in _BOX_REGEXES.get(key, []):
        m = rx.search(text)
        if not m:
            continue

        line_end = text.find("\n", m.end())
        if line_end == -1:
            remainder = text[m.end():m.end() + max_chars]
            return re.sub(r"^[\s:|.\-]+", "", remainder).strip()[:max_chars]

        # Same-line remainder
        remainder = text[m.end():line_end]
        remainder = re.sub(r"^[\s:|.\-]+", "", remainder).strip()

        # Follow-up lines until next box label or 4 lines
        collected = [remainder] if remainder else []
        pos = line_end + 1
        for _ in range(4):
            if pos >= len(text):
                break
            nxt_end = text.find("\n", pos)
            if nxt_end == -1:
                nxt_end = len(text)
            nxt = _clean_line(text[pos:nxt_end])
            if not nxt:
                pos = nxt_end + 1
                continue
            if _ANY_BOX_LABEL_RE.search(nxt):
                break
            collected.append(nxt)
            pos = nxt_end + 1

        if collected:
            return ", ".join(collected)[:max_chars]

    return ""


def _find_box_int(text: str, key: str) -> int | None:
    for rx in _BOX_REGEXES.get(key, []):
        m = rx.search(text)
        if not m:
            continue
        snippet = text[m.end():m.end() + 80]
        snippet = _ANY_BOX_LABEL_RE.split(snippet, maxsplit=1)[0]
        im = re.search(r"(?<!\d)(\d{1,4})(?!\d)", snippet)
        if im:
            return int(im.group(1))
        # Next-line fallback
        line_end = text.find("\n", m.end())
        if line_end != -1:
            next_end = text.find("\n", line_end + 1)
            if next_end == -1:
                next_end = len(text)
            next_line = text[line_end + 1:next_end]
            if not _ANY_BOX_LABEL_RE.search(next_line):
                im = re.search(r"(?<!\d)(\d{1,4})(?!\d)", next_line)
                if im:
                    return int(im.group(1))
    return None


# ---------------------------------------------------------------------
# Checkbox detection (box 6, box 7)
# ---------------------------------------------------------------------

_CHECKED_RE = re.compile(
    r"(?:\[\s*[xX✓✔]\s*\]|\b[xX✓✔]\b|\bchecked\b|\byes\b|\btrue\b)",
)


def _find_box_bool(text: str, key: str) -> bool:
    for rx in _BOX_REGEXES.get(key, []):
        m = rx.search(text)
        if not m:
            continue
        line_end = text.find("\n", m.end())
        if line_end == -1:
            line_end = min(len(text), m.end() + 100)
        snippet = text[m.end():line_end]
        nxt = _ANY_BOX_LABEL_RE.search(snippet)
        if nxt:
            snippet = snippet[:nxt.start()]
        if _CHECKED_RE.search(snippet):
            return True
    return False


# ---------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------

_MONEY_FIELDS = [
    "box1_mortgage_interest",
    "box2_outstanding_principal",
    "box4_refund_overpaid_interest",
    "box5_mortgage_insurance_premiums",
]

_BOOL_FIELDS = [
    "box6_points_paid",
    "box7_property_same_as_recipient",
]


def parse_1098(pages: list[dict], filename: str = "") -> dict[str, Any]:
    """Parse a Form 1098 (mortgage interest) using page 1 only."""

    text = _join_first_page_text(pages)
    if not text:
        raise ValueError(
            "The uploaded Form 1098 contained no readable text on page 1. "
            "If this is a scanned PDF, OCR must run in app.py."
        )

    warnings: list[str] = []

    recipient_tin, payer_tin = _extract_tins(text)
    recipient_name = _extract_name(text, "recipient")
    payer_name = _extract_name(text, "payer")
    recipient_address = _extract_address(text, "recipient")
    payer_address = _extract_address(text, "payer")

    if not recipient_tin:
        warnings.append("Could not locate the recipient (lender) TIN.")
    if not payer_tin:
        warnings.append("Could not locate the payer (borrower) TIN.")
    if not recipient_name:
        warnings.append("Could not confidently locate the recipient (lender) name.")
    if not payer_name:
        warnings.append("Could not confidently locate the payer (borrower) name.")

    result: dict[str, Any] = {
        "form_type": "1098",
        "filename": filename,
        "tax_year": _extract_tax_year(text) or 2025,
        "account_number": "",
        "recipient_tin": recipient_tin,
        "recipient_name": recipient_name,
        "recipient_address": recipient_address,
        "recipient_phone": _extract_phone(text),
        "payer_tin": payer_tin,
        "payer_name": payer_name,
        "payer_address": payer_address,
        "box3_origination_date": _find_box_date(text, "box3_origination_date"),
        "box8_property_address": _find_box_text(text, "box8_property_address"),
        "box9_num_properties": _find_box_int(text, "box9_num_properties"),
        "box10_other": _find_box_text(text, "box10_other"),
        "box11_acquisition_date": _find_box_date(text, "box11_acquisition_date"),
    }

    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(k) is not None for k 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