import io
import itertools
import os
import re
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal

import fitz
import pytesseract
from PIL import Image, ImageFilter, ImageOps
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware


app = FastAPI(
    title="OLPMS Form 941 Python Parser",
    version="1.3.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",
        "http://127.0.0.1:5173",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

TESSERACT_PATH = os.getenv(
    "TESSERACT_CMD",
    r"C:\Program Files\Tesseract-OCR\tesseract.exe",
)

if os.path.isfile(TESSERACT_PATH):
    pytesseract.pytesseract.tesseract_cmd = TESSERACT_PATH

# Scanned pages are upscaled to roughly this width before OCR.
OCR_TARGET_WIDTH = 2500


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# OCR look-alikes that appear inside numbers
DIGIT_FIX = str.maketrans({
    "O": "0", "o": "0", "I": "1", "l": "1", "|": "1",
    "]": "1", "[": "1", "B": "8", "a": "8", "S": "5",
})
EIN_FIX = str.maketrans({"O": "0", "o": "0", "I": "1", "l": "1", "|": "1"})

# An amount MUST have a decimal separator and two cents digits. A single
# stray trailing 0/O after the cents ("3934 . O00") is tolerated.
AMT_RE = re.compile(
    r"(?<![A-Za-z0-9.,])"
    r"(?P<whole>[\dBa][\dOoBaSIl|\],]*)"
    r"\s*[.,·•«»]\s*"
    r"(?P<cents>[\dOoIl|]{2})[0Oo]?"
    r"(?![\dA-Za-z])"
)

EIN_LOOSE = re.compile(
    r"(?<![\dA-Za-z])(\d{2})\s*[-–—]\s*([\dOoIl|?]{7})(?![\dA-Za-z])"
)
EIN_STRICT = re.compile(r"^\d{2}-\d{7}$")
PTIN_STRICT = re.compile(r"^P\d{8}$")


def clean(value):
    if value is None:
        return None

    value = re.sub(r"\s+", " ", str(value)).strip()
    return value or None


def to_amount(whole, cents):
    whole = whole.translate(DIGIT_FIX).replace(",", "")
    cents = cents.translate(DIGIT_FIX)

    if not (whole.isdigit() and cents.isdigit()):
        return None

    return float(Decimal(f"{whole}.{cents}"))


def amounts_in(text):
    values = [
        to_amount(m["whole"], m["cents"])
        for m in AMT_RE.finditer(text or "")
    ]
    return [v for v in values if v is not None]


def labeled_amounts(text, label_patterns):
    """All amounts that follow the first label match that has any amount."""
    lines = (text or "").splitlines()

    for pattern in label_patterns:
        for line in lines:
            match = re.search(pattern, line, re.I)
            if not match:
                continue

            values = amounts_in(line[match.end():])
            if values:
                return values

    return []


def find_labeled_amount(text, label_patterns, occurrence=-1):
    """Amount on a line matching a label/anchor. None if nothing reliable."""
    values = labeled_amounts(text, label_patterns)

    if not values:
        return None

    try:
        return values[occurrence]
    except IndexError:
        return None


def find_first_match(pattern, text, group=1):
    match = re.search(pattern, text or "", re.I | re.M)
    return clean(match.group(group)) if match else None


def find_ein(*texts):
    """
    Returns (clean_ein, partial_ein) from OCR text. Prefers a complete EIN.
    A partly readable one is returned separately and never guessed.
    """
    partial = None

    for text in texts:
        for m in EIN_LOOSE.finditer(text or ""):
            digits = m.group(2).translate(EIN_FIX)
            ein = f"{m.group(1)}-{digits}"

            if re.fullmatch(r"\d{7}", digits):
                return ein, None

            partial = partial or ein

    return None, partial


def find_employer_name(*pages):
    pattern = (
        r"(?i:Name\s*\(not\s+your\s+trade\s+name\))\s*[:|]?\s*\n*\s*"
        r"([A-Za-z0-9][A-Za-z0-9 &.,'\-/]{2,}?)\s*(?:\n|$)"
    )

    for text in pages:
        m = re.search(pattern, text or "")
        if m:
            return clean(m.group(1))

    return None


def format_phone(value):
    digits = re.sub(r"\D", "", value or "")
    if len(digits) == 11 and digits.startswith("1"):
        digits = digits[1:]
    if len(digits) != 10:
        return None
    return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"


# ---------------------------------------------------------------------------
# PDF text / OCR extraction
# ---------------------------------------------------------------------------

def page_image(page, document):
    """
    Best grayscale image of a scanned page. A scan normally holds exactly one
    embedded image; reading it directly avoids the blur from re-rendering.
    """
    images = page.get_images(full=True)

    if len(images) == 1:
        try:
            info = document.extract_image(images[0][0])
            image = Image.open(io.BytesIO(info["image"])).convert("L")
            if image.width >= 600:
                return image
        except Exception:
            pass

    pix = page.get_pixmap(matrix=fitz.Matrix(300 / 72, 300 / 72), alpha=False)
    return Image.open(io.BytesIO(pix.tobytes("png"))).convert("L")


def prepare_for_ocr(image, sharpen):
    scale = OCR_TARGET_WIDTH / image.width

    if scale > 1.05:
        image = image.resize(
            (round(image.width * scale), round(image.height * scale)),
            Image.LANCZOS,
        )

    image = ImageOps.autocontrast(image)

    if sharpen:
        image = image.filter(ImageFilter.UnsharpMask(2, 150, 3))

    return image


def ocr_image(image):
    text = pytesseract.image_to_string(image, lang="eng", config="--psm 6")

    if len(text.strip()) < 10:
        text = pytesseract.image_to_string(image, lang="eng", config="--psm 3")

    return text


def extract_text(pdf_bytes):
    """
    Returns one dict per page. Scanned pages are OCR'd twice (plain and
    sharpened) because the two passes misread different digits; the parser
    uses both. `image` is kept for the small-box reads and is not returned
    to the client.
    """
    pages = []
    ocr_jobs = []  # (page_index, sharpen, prepared_image)

    document = fitz.open(stream=pdf_bytes, filetype="pdf")

    try:
        for index, page in enumerate(document):
            text = page.get_text("text", sort=True) or ""

            item = {
                "page": index + 1,
                "text": text,
                "text_alt": "",
                "width": round(page.rect.width, 2),
                "height": round(page.rect.height, 2),
                "source": "pdf_text",
                "image": None,
            }

            if len(re.sub(r"\s+", "", text)) < 40:
                tesseract_cmd = pytesseract.pytesseract.tesseract_cmd

                if not os.path.isfile(tesseract_cmd):
                    item["ocr_error"] = (
                        "Tesseract executable was not found. "
                        "Install Tesseract or configure TESSERACT_CMD."
                    )
                else:
                    try:
                        base = page_image(page, document)
                        item["image"] = base
                        item["source"] = "ocr"

                        for sharpen in (False, True):
                            ocr_jobs.append(
                                (index, sharpen, prepare_for_ocr(base, sharpen))
                            )
                    except Exception as exc:
                        item["ocr_error"] = (
                            f"OCR failed on page {index + 1}: {exc}"
                        )

            pages.append(item)
    finally:
        document.close()

    if ocr_jobs:
        def run(job):
            index, sharpen, image = job
            try:
                return index, sharpen, ocr_image(image), None
            except Exception as exc:
                return index, sharpen, "", exc

        workers = max(2, min(4, os.cpu_count() or 2))

        with ThreadPoolExecutor(max_workers=workers) as pool:
            for index, sharpen, text, exc in pool.map(run, ocr_jobs):
                if exc is not None:
                    pages[index]["ocr_error"] = (
                        f"OCR failed on page {index + 1}: {exc}"
                    )
                elif sharpen:
                    pages[index]["text_alt"] = text
                else:
                    pages[index]["text"] = text

    return pages


# ---------------------------------------------------------------------------
# Small-box OCR (names, EIN, PTIN ...) on the scanned image
# ---------------------------------------------------------------------------
# Positions are fractions of the page and match the standard IRS Form 941
# layout. Every read is validated, and the parser falls back to the page
# text when a box returns nothing usable.

def zone_ocr(image, x0, x1, yc, whitelist=None, half=0.012, scale=4):
    width, height = image.size
    box = image.crop((
        int(width * x0), int(height * (yc - half)),
        int(width * x1), int(height * (yc + half)),
    ))
    box = box.resize((box.width * scale, box.height * scale), Image.LANCZOS)
    box = ImageOps.expand(ImageOps.autocontrast(box), border=30, fill=255)

    config = "--psm 7"
    if whitelist:
        config += f" -c tessedit_char_whitelist={whitelist}"

    return pytesseract.image_to_string(box, config=config).strip()


def zone_read(image, box, yc, kind="text", whitelist=None):
    """Read one box a few pixels apart and return the value that repeats."""
    if image is None:
        return None

    x0, x1 = box
    votes = Counter()

    for dy in (0.0, 0.004, -0.004, 0.008, -0.008):
        try:
            raw = zone_ocr(image, x0, x1, yc + dy, whitelist=whitelist)
        except Exception:
            continue

        raw = re.sub(r"^[^\w]+|[^\w.)]+$", "", raw).strip()
        value = None

        if kind == "ein":
            m = EIN_LOOSE.search(raw)
            if m:
                digits = m.group(2).translate(EIN_FIX)
                if digits.isdigit():
                    value = f"{m.group(1)}-{digits}"
        elif kind == "ptin":
            candidate = re.sub(r"[\s-]", "", raw).upper()
            candidate = candidate.replace("O", "0")
            if PTIN_STRICT.match(candidate):
                value = candidate
        elif kind == "phone":
            value = format_phone(raw)
        elif kind == "zip":
            m = re.search(r"\b(\d{5}(?:-\d{4})?)\b", raw)
            value = m.group(1) if m else None
        elif kind == "state":
            m = re.search(r"\b([A-Z]{2})\b", raw.upper())
            value = m.group(1) if m else None
        else:
            letters = sum(ch.isalpha() for ch in raw)
            if len(raw) >= 3 and letters >= 0.6 * len(raw):
                value = clean(raw.strip("|[]_ "))

        if value:
            votes[value] += 1
            if votes.most_common(1)[0][1] >= 2:
                break

    return votes.most_common(1)[0][0] if votes else None


# (x-range, y-centre) of each box
PAGE3_ZONES = {
    "signer_name": ((0.71, 0.93), 0.629),
    "signer_title": ((0.71, 0.93), 0.6597),
    "signer_phone": ((0.74, 0.94), 0.699),
    "designee_name": ((0.42, 0.71), 0.502),
    "designee_phone": ((0.74, 0.94), 0.502),
    "preparer_name": ((0.19, 0.63), 0.759),
    "preparer_ptin": ((0.72, 0.94), 0.759),
    "preparer_firm_name": ((0.19, 0.63), 0.819),
    "preparer_firm_ein": ((0.72, 0.94), 0.819),
    "preparer_address": ((0.19, 0.63), 0.848),
    "preparer_phone": ((0.72, 0.94), 0.848),
    "preparer_city": ((0.19, 0.50), 0.874),
    "preparer_state": ((0.56, 0.63), 0.874),
    "preparer_zip": ((0.72, 0.94), 0.874),
}
PAGE3_KINDS = {
    "signer_phone": ("phone", "0123456789 -()"),
    "designee_phone": ("phone", "0123456789 -()"),
    "preparer_ptin": ("ptin", "P0123456789 "),
    "preparer_firm_ein": ("ein", "0123456789-"),
    "preparer_phone": ("phone", "0123456789 -()"),
    "preparer_zip": ("zip", "0123456789-"),
    "preparer_state": ("state", None),
}


def read_page3_zones(image):
    if image is None:
        return {}

    def job(item):
        key, (box, yc) = item
        kind, whitelist = PAGE3_KINDS.get(key, ("text", None))
        return key, zone_read(image, box, yc, kind=kind, whitelist=whitelist)

    with ThreadPoolExecutor(max_workers=4) as pool:
        return dict(pool.map(job, PAGE3_ZONES.items()))


# ---------------------------------------------------------------------------
# Form 941 parser
# ---------------------------------------------------------------------------

def parse_941(pages, filename=None):
    def page_text(i):
        if i >= len(pages):
            return ""
        return (pages[i].get("text") or "") + "\n" + (pages[i].get("text_alt") or "")

    p1, p2, p3, p4 = (page_text(i) for i in range(4))
    img2 = pages[1].get("image") if len(pages) > 1 else None
    img3 = pages[2].get("image") if len(pages) > 2 else None

    full_text = "\n".join(page_text(i) for i in range(len(pages)))
    warnings = []
    auto_filled = []  # values calculated or cross-checked from other lines
    result = {}

    # ---- Tax year
    year_match = (
        re.search(r"941\s+for\s+(20\d{2})", p1, re.I)
        or re.search(r"Calendar year\s*[\[\(]?\s*(20\d{2})", full_text, re.I)
        or re.search(r"\b(20\d{2})\b", p1)
    )
    result["tax_year"] = int(year_match.group(1)) if year_match else None

    # ---- Employer (the page 2 header is the cleanest copy)
    ein = zone_read(
        img2, (0.66, 0.95), 0.081, kind="ein", whitelist="0123456789-"
    ) if img2 is not None else None
    partial = None

    if not ein:
        ein, partial = find_ein(p2, p4, p1)

    result["employer_ein"] = ein
    result["employer_ein_partial"] = partial

    if partial and not ein:
        warnings.append(
            f"Employer EIN only partly readable ({partial}); enter it manually."
        )
    elif not ein:
        warnings.append("Employer EIN could not be confidently extracted.")

    result["employer_name"] = find_employer_name(p2, p4)
    result["trade_name"] = None  # not reliably readable from scans

    # ---- Source text for verification
    result["source_page_count"] = len(pages)
    result["source_pages"] = [
        {
            "page": page["page"],
            "source": page.get("source", "unknown"),
            "text": page.get("text", ""),
            "text_alt": page.get("text_alt", ""),
            **(
                {"ocr_error": page["ocr_error"]}
                if page.get("ocr_error")
                else {}
            ),
        }
        for page in pages
    ]

    # ---- Single-amount lines
    searchable_text = p1 + "\n" + p2

    field_patterns = {
        "line2_wages": [
            r"Wages\s*,?\s*tips\s*,?\s*and\s*other\s*compensation",
        ],
        "line3_fed_tax": [r"Federal\s+income\s+tax\s+withheld"],
        "line5e_total_ss_medicare": [
            r"Total\s+social\s+security\s+and\s+Medicare\s+taxes",
        ],
        "line5f_unreported_tips": [
            r"Section\s+3121\s*\(?q\)?\s*Notice\s+and\s+Demand",
        ],
        "line6_total_before_adj": [
            r"Total\s+taxes\s+before\s+adjustments",
            r"^\s*6\b",
        ],
        "line7_fractions_cents": [r"adjustment\s+for\s+fractions\s+of\s+cents"],
        "line8_sick_pay_adj": [r"adjustment\s+for\s+sick\s+pay"],
        "line9_tips_gtl_adj": [r"group[-\s]*term\s*life"],
        "line10_total_after_adj": [
            r"Total\s+taxes\s+after\s+adjustments\.\s*Combine",
            r"^\s*10\b",
        ],
        "line11g_total_nonrefundable": [r"Total\s+nonrefundable\s+credits"],
        "line12_total_taxes": [r"^\s*12\b"],
        "line13a_total_deposits": [
            r"Total\s+deposits\s+for\s+this\s+quarter",
            r"overpayments\s+applied\s+from\s+Form\s+941-X",
        ],
        "line13g_total_deposits_credits": [
            r"Total\s+deposits\s+and\s+refundable",
        ],
        "line14_balance_due": [r"Balance\s+due"],
        "line15_overpayment": [r"Overpayment"],
    }

    for key, patterns in field_patterns.items():
        result[key] = find_labeled_amount(searchable_text, patterns)

    # ---- Lines 5a-5d (column 1 = wages, column 2 = tax)
    vals5a = labeled_amounts(p1, [r"Taxable\s+social\s+security\s+wages"])
    vals5b = labeled_amounts(p1, [r"Taxable\s+social\s+security\s+tips"])
    vals5c = labeled_amounts(p1, [r"Taxable\s+Medicare\s+wages"])
    vals5d = labeled_amounts(p1, [r"Additional\s+Medicare\s+Tax\s+withholding"])

    def split_cols(values):
        if len(values) >= 2:
            return values[0], values[-1]
        return None, (values[0] if values else None)

    result["line5a_ss_wages"], result["line5a_ss_tax"] = split_cols(vals5a)
    result["line5b_ss_tips"], result["line5b_ss_tips_tax"] = split_cols(vals5b)
    result["line5c_medicare_wages"], result["line5c_medicare_tax"] = split_cols(vals5c)
    result["line5d_addl_medicare_wages"], result["line5d_addl_medicare_tax"] = split_cols(vals5d)

    # ---- Quarter
    result["quarter"] = None

    for pattern in (
        r"(?<![A-Za-z])(?:[Xx✓✔☒■])\s*([1-4])\s*:\s*"
        r"(?:January|April|July|October)",
        r"\[\s*[Xx✓✔☒]\s*\]\s*([1-4])\s*:\s*"
        r"(?:January|April|July|October)",
    ):
        m = re.search(pattern, p1, re.I)
        if m:
            result["quarter"] = int(m.group(1))
            break

    if result["quarter"] is None and filename:
        m = re.search(
            r"(?:^|[^A-Za-z0-9])(?:([1-4])[qQ](?:\d{2})?|[qQ]([1-4]))"
            r"(?![A-Za-z0-9])",
            filename,
        )
        if m:
            result["quarter"] = int(m.group(1) or m.group(2))
            auto_filled.append("quarter")

    # ---- Total liability and monthly liabilities
    liab_p2 = find_labeled_amount(p2, [r"Total\s+liability\s+for\s+quarter"])
    tail = re.search(
        r"Total\s+liability\s+for\s+the\s+quarter(.*)", p4, re.I | re.S
    )
    p4_vals = amounts_in(tail.group(1)) if tail else []
    liab_p4 = p4_vals[0] if p4_vals else None
    result["total_liability"] = liab_p2 if liab_p2 is not None else liab_p4

    month_candidates = []

    for n in (1, 2, 3):
        cands = []

        # Part 2 on page 2 (preferred)
        v = find_labeled_amount(p2, [rf"Month\s*{n}\b"], occurrence=0)
        if v is not None:
            cands.append(v)

        # Schedule B on page 4
        seg = re.search(
            rf"Month\s*{n}\b(.*?)(?=Month\s*[123]\b|Total\s+liability|$)",
            p4,
            re.I | re.S,
        )
        if seg:
            cands.extend(amounts_in(seg.group(1))[:2])

        month_candidates.append(list(dict.fromkeys(cands)))

    for n, cands in enumerate(month_candidates, start=1):
        result[f"month{n}_liability"] = cands[0] if cands else None

    # When the months disagree with the total, pick the combination that adds up.
    total = result["total_liability"]
    if total is not None and all(month_candidates):
        current_sum = sum(result[f"month{n}_liability"] for n in (1, 2, 3))

        if abs(current_sum - total) > 0.02:
            for combo in itertools.product(*month_candidates):
                if abs(sum(combo) - total) <= 0.02:
                    for n, value in enumerate(combo, start=1):
                        result[f"month{n}_liability"] = value
                    break

    month_keys = [f"month{n}_liability" for n in (1, 2, 3)]
    missing_months = [k for k in month_keys if result[k] is None]

    if len(missing_months) == 1 and total is not None:
        known = sum(result[k] for k in month_keys if result[k] is not None)
        result[missing_months[0]] = round(total - known, 2)
        auto_filled.append(missing_months[0])

    # ---- Totals: lines 6, 10, 12 and total liability agree when there are
    #      no adjustments or credits
    no_adj = all(
        result.get(k) is None
        for k in (
            "line7_fractions_cents",
            "line8_sick_pay_adj",
            "line9_tips_gtl_adj",
            "line11g_total_nonrefundable",
        )
    )
    votes = [
        v
        for v in (
            result["line6_total_before_adj"],
            result["line10_total_after_adj"],
            result["line12_total_taxes"],
            liab_p2,
            liab_p4,
        )
        if v is not None
    ]

    if no_adj and votes:
        value, count = Counter(votes).most_common(1)[0]
        if count >= 2:
            for key in (
                "line6_total_before_adj",
                "line10_total_after_adj",
                "line12_total_taxes",
                "total_liability",
            ):
                if result[key] != value:
                    result[key] = value
                    auto_filled.append(key)

    # ---- Line 3 = line 6 - line 5e - line 5f (always true on the form)
    e5 = result["line5e_total_ss_medicare"]
    l6 = result["line6_total_before_adj"]

    if l6 is not None and e5 is not None:
        derived3 = round(l6 - e5 - (result["line5f_unreported_tips"] or 0), 2)
        read3 = result["line3_fed_tax"]

        if derived3 >= 0 and (read3 is None or abs(read3 - derived3) > 0.02):
            result["line3_fed_tax"] = derived3
            auto_filled.append("line3_fed_tax")

    # ---- Wages: lines 2, 5a and 5c are confirmed by line 5e.
    #      Social security 12.4% + Medicare 2.9% = 15.3% of taxable wages.
    def reconcile_wages():
        if e5 is None:
            return None

        tax5a = result["line5a_ss_tax"]
        candidates = [
            result["line2_wages"],
            result["line5a_ss_wages"],
            result["line5c_medicare_wages"],
            round(tax5a / 0.124, 2) if tax5a else None,
            round(e5 / 0.153, 2),
        ]

        for c in candidates:
            if c and abs(round(c * 0.153, 2) - e5) <= 0.05:
                return c

        return None

    wages = reconcile_wages()

    if wages is not None:
        if result["line5a_ss_wages"] != wages:
            result["line5a_ss_wages"] = wages
            auto_filled.append("line5a_ss_wages")

        tax5a = round(wages * 0.124, 2)
        if result["line5a_ss_tax"] != tax5a:
            result["line5a_ss_tax"] = tax5a
            auto_filled.append("line5a_ss_tax")

        if result["line5c_medicare_wages"] != wages:
            result["line5c_medicare_wages"] = wages
            auto_filled.append("line5c_medicare_wages")

        tax5c = round(wages * 0.029, 2)
        if result["line5c_medicare_tax"] != tax5c:
            result["line5c_medicare_tax"] = tax5c
            auto_filled.append("line5c_medicare_tax")

        # Line 2 can be larger than taxable wages, but never smaller.
        line2 = result["line2_wages"]
        if line2 is None or line2 < wages - 0.01:
            result["line2_wages"] = wages
            auto_filled.append("line2_wages")
    elif e5 is not None and (vals5a or vals5c):
        warnings.append(
            "Lines 5a / 5c could not be matched to line 5e; check them against the PDF."
        )

    if result["line2_wages"] is None:
        warnings.append("Line 2 wages could not be read; enter it manually.")

    # ---- Signer, designee and paid preparer (page 3)
    zones = read_page3_zones(img3)
    result.update({k: zones.get(k) for k in PAGE3_ZONES})

    # Fall back to the page text for anything a box did not return.
    if not result["designee_name"]:
        result["designee_name"] = find_first_match(
            r"Designee.?s\s+name\s+and\s+phone\s+number\s+"
            r"([A-Z][A-Za-z.'\- ]+?)\s+\d",
            p3,
        )

    if not result["preparer_name"]:
        result["preparer_name"] = find_first_match(
            r"Preparer.?s\s*name\s*\|?\s*([A-Z][A-Za-z.'\- ]+?)\s+PTIN", p3
        )

    if not result["preparer_ptin"]:
        m = re.search(r"\bP\s?(\d[\d ]{7,9}\d)\b", p3)
        if m:
            digits = re.sub(r"\D", "", m.group(1))
            if len(digits) == 8:
                result["preparer_ptin"] = f"P{digits}"

    if not result["preparer_firm_ein"]:
        prep = re.search(r"Paid\s+Preparer\s+Use\s+Only(.*)", p3, re.I | re.S)
        ein2, _ = find_ein(prep.group(1) if prep else "")
        result["preparer_firm_ein"] = ein2

    if not result["preparer_firm_name"]:
        result["preparer_firm_name"] = find_first_match(
            r"([A-Z][A-Z&.,' ]{5,}?(?:INC|LLC|CORP|CPA|SERVICES)\.?)\s+EIN", p3
        )

    if not result["signer_name"]:
        result["signer_name"] = find_first_match(
            r"Print\s+your\s*\n\s*([A-Z][A-Za-z.'\- ]{4,})\s*\n", p3
        )

    # The person signing for the business is usually the preparer/designee.
    if not result["signer_name"]:
        fallback = result["preparer_name"] or result["designee_name"]
        if fallback:
            result["signer_name"] = fallback
            auto_filled.append("signer_name")

    if not result["signer_phone"]:
        result["signer_phone"] = result["preparer_phone"] or result["designee_phone"]
        if result["signer_phone"]:
            auto_filled.append("signer_phone")

    # ---- Consistency checks
    def check_sum(keys, target, message):
        values = [result.get(k) for k in keys]
        expected = result.get(target)

        if expected is not None and all(v is not None for v in values):
            if abs(sum(values) - expected) > 0.02:
                warnings.append(message)

    check_sum(
        ["line3_fed_tax", "line5e_total_ss_medicare", "line5f_unreported_tips"],
        "line6_total_before_adj",
        "Line 6 does not match lines 3 + 5e + 5f.",
    )
    check_sum(
        ["month1_liability", "month2_liability", "month3_liability"],
        "total_liability",
        "Monthly liabilities do not match total liability.",
    )

    if not result["employer_name"]:
        warnings.append("Employer name could not be confidently extracted.")
    if result["quarter"] is None:
        warnings.append("Quarter checkbox needs verification.")
    if result["tax_year"] is None:
        warnings.append("Tax year could not be confidently extracted.")
    if any(p.get("ocr_error") for p in pages):
        warnings.append("One or more pages could not be OCR-processed.")
    if not full_text.strip():
        warnings.append("No readable PDF text was extracted.")
    if any(p.get("source") == "ocr" for p in pages):
        warnings.append(
            "OCR-derived values require review against the original PDF."
        )

    result["parse_warnings"] = warnings
    result["auto_filled"] = sorted(set(auto_filled))
    result["review_required"] = bool(warnings)

    return result


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

@app.get("/health")
def health():
    return {
        "status": "ok",
        "service": "olpms-941-python-parser",
    }


@app.post("/parse-941")
async def parse_941_endpoint(file: UploadFile = File(...)):
    if not file.filename or not file.filename.lower().endswith(".pdf"):
        raise HTTPException(
            status_code=400,
            detail="Please upload a PDF file.",
        )

    pdf_bytes = await file.read()

    if not pdf_bytes:
        raise HTTPException(
            status_code=400,
            detail="The uploaded PDF is empty.",
        )

    if len(pdf_bytes) > 25 * 1024 * 1024:
        raise HTTPException(
            status_code=413,
            detail="PDF is too large. Maximum allowed size is 25 MB.",
        )

    try:
        # OCR is slow and blocking, so keep it off the event loop.
        pages = await run_in_threadpool(extract_text, pdf_bytes)

        if not pages:
            raise HTTPException(
                status_code=422,
                detail="The PDF contains no readable pages.",
            )

        result = await run_in_threadpool(parse_941, pages, file.filename)
        result["filename"] = file.filename

        return result

    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(
            status_code=422,
            detail=f"Unable to parse this Form 941 PDF: {exc}",
        ) from exc