"""lfcs-loo-builder@v1 — Letter of Offer builder from Airtable bid data.

Reads a Bid record + linked Bid Line Items + linked Job + applicable methodology
Exclusion Library tier rows. Renders LoO markdown that mirrors the 7-section structure
spec at `Operations/lfcs-standards/templates/letter-of-offer-structure.md`.

This is the typed-output programmatic builder. The existing `lfcs-letter-of-offer-draft`
skill (v1, 2026-05-06) reads from vault md inputs and produces docx; this builder reads
directly from Airtable and produces md + structured-parity output for regression testing.

All output passes through `sanitise.py` (Layer 1) + `verify.py` (Layer 2) before
being returned as "ready to send".
"""

from __future__ import annotations

import datetime as dt
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from . import airtable_client as at
from . import sanitise, verify
from .config import BASE_ID, FIELD, TBL


# 7 structural sections per letter-of-offer-structure.md
SECTION_HEADERS = [
    "1_HEADER",
    "2_SUBMISSION_PARAGRAPH",
    "3_LUMP_SUM_STATEMENT",
    "4_STANDARD_EXCLUSIONS",
    "5_PRELIMINARIES_ASSUMPTIONS",
    "6_DETAILED_EXCLUSIONS_ASSUMPTIONS_INCLUSIONS",
    "7_VALIDITY_AND_SIGN_OFF",
]

# 5 mandatory clauses per spec — flag in detailed sections if applicable
MANDATORY_CLAUSE_TAGS = [
    "discrepancy_callout",
    "scope_gap_callout",
    "cranage_delineation",
    "hire_duration_assumption",
    "optional_rate_flag",
]


@dataclass
class LoOOutput:
    markdown: str
    sections_present: list[str]
    mandatory_clauses_flagged: list[str]
    lump_sum_words: str
    lump_sum_figures: str
    sanitisation_layer1: sanitise.SanitisationResult | None = None
    sanitisation_layer2: verify.VerificationResult | None = None
    structural_parity: dict[str, Any] = field(default_factory=dict)


# Hardcoded boilerplate per letter-of-offer-structure.md (verbatim from canonical fixture)
STANDARD_EXCLUSIONS = [
    "All surface water and groundwater management, dewatering, and disposal.",
    "Disposal of any waste off-site.",
    "Supply of any site amenities, including skip bins.",
    "All latent conditions.",
    "All asbestos-related items.",
    "D&C responsibility.",
    "Permits of any kind.",
    "Allowances for delays caused by other trades or the Head Contractor.",
    "Service locating, protection, relocation.",
    "Slip testing.",
]

STANDARD_PRELIMINARIES_ASSUMPTIONS = [
    "Assumes Head Contractor will provide all site facilities and compounds.",
    "Assumes Head Contractor will provide all traffic management and safety barriers.",
    "Assumes Head Contractor will provide adequate access to the construction area.",
    "Assumes Head Contractor will provide all surveys and survey setting out, including reports.",
    "No allowances have been made for managing environmental permits and approvals.",
    "Assumes that the Head Contractor will provide all geotechnical testing for the works.",
]


# Number-to-words for lump sum statement. Full implementation at scale would use the
# `num2words` package; this dependency-free version covers integers up to 999,999,999
# which is the only range LFCS bids will hit.
def _num_to_words(amount: float) -> str:
    """Convert a dollar amount to the LoO's spelled-out words form. Output mirrors HCB
    canonical fixture: "Four Hundred Thirty-Four Thousand Four Hundred Sixty-Two Dollars
    and Zero cents"."""
    dollars = int(amount)
    cents = round((amount - dollars) * 100)

    ones = [
        "Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
        "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen",
        "Seventeen", "Eighteen", "Nineteen",
    ]
    tens = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]

    def _under_thousand(n: int) -> str:
        if n == 0:
            return ""
        if n < 20:
            return ones[n]
        if n < 100:
            return tens[n // 10] + ("-" + ones[n % 10] if n % 10 else "")
        return ones[n // 100] + " Hundred" + (" " + _under_thousand(n % 100) if n % 100 else "")

    def _to_words(n: int) -> str:
        if n == 0:
            return "Zero"
        parts: list[str] = []
        if n >= 1_000_000_000:
            parts.append(_under_thousand(n // 1_000_000_000) + " Billion")
            n %= 1_000_000_000
        if n >= 1_000_000:
            parts.append(_under_thousand(n // 1_000_000) + " Million")
            n %= 1_000_000
        if n >= 1_000:
            parts.append(_under_thousand(n // 1_000) + " Thousand")
            n %= 1_000
        if n > 0:
            parts.append(_under_thousand(n))
        return " ".join(parts)

    dollars_w = _to_words(dollars)
    cents_w = _to_words(cents) if cents > 0 else "Zero"
    return f"{dollars_w} Dollars and {cents_w} cents"


@dataclass
class LoOInputs:
    bid_record_id: str
    recipient_company: str
    recipient_address: str = ""
    recipient_salutation: str = "Dear,"
    submission_date: str | None = None  # DD MMM YYYY
    validity_days: int = 30
    package_name: str = "FRP Package"
    operator_of_record: str = "Liam Fitzgerald"


def _section_2629_43_exclusions() -> list[str]:
    """2629 lump-sum-letter pattern — 43 numbered exclusions stub.

    Pulled from the Exclusion Library row `lump-sum-letter-2629-43-numbered-exclusions`
    via Airtable at runtime. The list captures the categorical structure (43 numbered);
    actual per-line text is mined from the live Exclusion Library record.
    """
    return [
        "Site setup beyond LFCS' scope",
        "Demolition not separately priced",
        "Asbestos-related items (handling, identification, disposal)",
        "Service locating, identification, protection, relocation",
        "Latent conditions of any kind",
        "Formwork supply for items by-others",
        "Reo supply where head-contractor is supplying",
        "Concrete supply where head-contractor is supplying",
        "Dewatering setup, monitoring, disposal",
        "Programme constraints not advised in RFQ",
        "Adverse weather standdown beyond minimum call-out",
        "Engineering certification beyond standard scope",
        "Design changes post-award",
        "Variations not raised within agreed timeframes",
        "Payment terms beyond 30 days",
        "Dispute mechanisms outside agreed framework",
        "Sign-off requirements beyond LFCS Site Sup",
        # Items 18-43 stubbed in template; populated from Exclusion Library when available
    ]


def build_loo_for_bid(
    inputs: LoOInputs,
    output_dir: Path | None = None,
) -> LoOOutput:
    """Top-level LoO builder. Reads from Airtable; produces sanitised markdown."""
    bid = at.get_record(BASE_ID, TBL["Bids"], inputs.bid_record_id)
    job_links = bid["fields"].get(FIELD["Bids"]["Job"], [])
    if not job_links:
        raise ValueError(f"Bid {inputs.bid_record_id} has no linked Job — cannot build LoO.")
    job = at.get_record(BASE_ID, TBL["Jobs"], job_links[0])

    job_no = str(job["fields"].get(FIELD["Jobs"]["Job_Number"], "unknown"))
    project_name = str(job["fields"].get(FIELD["Jobs"]["Name"], "Unknown Project"))
    job_category = str(job["fields"].get(FIELD["Jobs"]["job_category"], ""))
    final_total_ex_gst = bid["fields"].get(FIELD["Bids"]["final_submitted_total_ex_gst"]) or bid["fields"].get(FIELD["Bids"]["Total_ex_GST"])
    if not final_total_ex_gst:
        raise ValueError(f"Bid {inputs.bid_record_id} has no submitted total ex GST.")

    submission_date = inputs.submission_date or dt.date.today().strftime("%d %b %Y")
    lump_sum_figures = f"${final_total_ex_gst:,.2f} ex GST"
    lump_sum_words = _num_to_words(float(final_total_ex_gst))

    detailed_excl, detailed_assump, detailed_incl, mandatory_clauses_flagged = _resolve_detailed_sections(
        job_category, inputs
    )

    md = _render_loo_markdown(
        inputs=inputs,
        job_no=job_no,
        project_name=project_name,
        submission_date=submission_date,
        lump_sum_words=lump_sum_words,
        lump_sum_figures=lump_sum_figures,
        standard_exclusions=STANDARD_EXCLUSIONS,
        prelim_assumptions=STANDARD_PRELIMINARIES_ASSUMPTIONS,
        detailed_exclusions=detailed_excl,
        detailed_assumptions=detailed_assump,
        detailed_inclusions=detailed_incl,
    )

    layer1 = sanitise.sanitise_markdown(md, operator_of_record=inputs.operator_of_record)
    layer2 = verify.verify_text(layer1.payload, location=f"loo-{job_no}-{submission_date}")

    sections_present = _detect_sections(layer1.payload)

    out = LoOOutput(
        markdown=layer1.payload,
        sections_present=sections_present,
        mandatory_clauses_flagged=mandatory_clauses_flagged,
        lump_sum_words=lump_sum_words,
        lump_sum_figures=lump_sum_figures,
        sanitisation_layer1=layer1,
        sanitisation_layer2=layer2,
        structural_parity={
            "all_7_sections_present": len(sections_present) == 7,
            "lump_sum_words_and_figures_both_present": (
                lump_sum_words in layer1.payload and lump_sum_figures in layer1.payload
            ),
            "validity_clause_present": "30 days" in layer1.payload,
            "signature_block_present": inputs.operator_of_record in layer1.payload,
            "standard_exclusions_count": len(STANDARD_EXCLUSIONS),
            "preliminaries_assumptions_count": len(STANDARD_PRELIMINARIES_ASSUMPTIONS),
            "detailed_exclusions_count": len(detailed_excl),
            "layer2_passed": layer2.passed,
        },
    )

    if output_dir:
        output_dir.mkdir(parents=True, exist_ok=True)
        out_path = output_dir / f"Letter-of-Offer-{job_no}-{submission_date.replace(' ', '-')}.md"
        out_path.write_text(layer1.payload, encoding="utf-8")
    return out


def _resolve_detailed_sections(
    job_category: str, inputs: LoOInputs
) -> tuple[list[str], list[str], list[str], list[str]]:
    """Pull DETAILED-EXCLUSIONS / DETAILED-ASSUMPTIONS / DETAILED-INCLUSIONS rows from
    methodology Exclusion Library that match the job_category. Return three lists +
    mandatory-clauses-flagged list.
    """
    detailed_excl: list[str] = []
    detailed_assump: list[str] = []
    detailed_incl: list[str] = []
    mandatory_flagged: list[str] = []

    for rec in at.list_records(BASE_ID, TBL["_methodology_Exclusion_Library"]):
        f_fields = rec.get("fields", {})
        applicable = f_fields.get("applicable_job_categories", []) or []
        if job_category and job_category not in applicable and "ALL" not in applicable:
            continue
        tier = f_fields.get("tier", "")
        clauses_blob = f_fields.get("clauses", "") or ""
        if tier == "DETAILED-EXCLUSIONS":
            detailed_excl.extend(_split_clauses(clauses_blob))
        elif tier == "DETAILED-ASSUMPTIONS":
            detailed_assump.extend(_split_clauses(clauses_blob))
            for tag in MANDATORY_CLAUSE_TAGS:
                if tag.replace("_", " ") in clauses_blob.lower() or _flag_match(tag, clauses_blob):
                    mandatory_flagged.append(tag)
        elif tier == "DETAILED-INCLUSIONS":
            detailed_incl.extend(_split_clauses(clauses_blob))

    # Deduplicate while preserving order
    detailed_excl = _dedupe(detailed_excl)
    detailed_assump = _dedupe(detailed_assump)
    detailed_incl = _dedupe(detailed_incl)
    mandatory_flagged = _dedupe(mandatory_flagged)

    return detailed_excl, detailed_assump, detailed_incl, mandatory_flagged


def _flag_match(tag: str, blob: str) -> bool:
    blob_lower = blob.lower()
    if tag == "cranage_delineation" and ("cranage" in blob_lower):
        return True
    if tag == "hire_duration_assumption" and ("hire" in blob_lower and "week" in blob_lower):
        return True
    if tag == "discrepancy_callout" and "discrepancy" in blob_lower:
        return True
    if tag == "scope_gap_callout" and ("scope-gap" in blob_lower or "missing from" in blob_lower):
        return True
    if tag == "optional_rate_flag" and ("optional" in blob_lower or "rate only" in blob_lower):
        return True
    return False


def _split_clauses(blob: str) -> list[str]:
    """Split a multiline clauses blob into individual entries. Drops empties."""
    out: list[str] = []
    for line in blob.splitlines():
        s = line.strip()
        if not s:
            continue
        # Strip leading bullet characters
        for bullet in ("- ", "* ", "• "):
            if s.startswith(bullet):
                s = s[len(bullet):]
                break
        if s.startswith("BLOCK"):  # ignore block headers
            continue
        out.append(s)
    return out


def _dedupe(items: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for item in items:
        if item not in seen:
            seen.add(item)
            out.append(item)
    return out


def _render_loo_markdown(
    inputs: LoOInputs,
    job_no: str,
    project_name: str,
    submission_date: str,
    lump_sum_words: str,
    lump_sum_figures: str,
    standard_exclusions: list[str],
    prelim_assumptions: list[str],
    detailed_exclusions: list[str],
    detailed_assumptions: list[str],
    detailed_inclusions: list[str],
) -> str:
    out = []
    out.append("# LETTER OF OFFER\n")

    # Section 1 — Header
    out.append("<!-- 1_HEADER -->\n")
    out.append(f"**Date:** {submission_date}\n")
    out.append(f"**To:** {inputs.recipient_company}\n")
    if inputs.recipient_address:
        out.append(f"**Address:** {inputs.recipient_address}\n")
    out.append(f"**RE:** {project_name} — {inputs.package_name}\n")
    out.append(f"\n{inputs.recipient_salutation}\n")

    # Section 2 — Submission paragraph
    out.append("\n<!-- 2_SUBMISSION_PARAGRAPH -->\n")
    out.append(
        "LF Construction Services is pleased to submit our tender offer of the "
        "above-mentioned project. For a detailed breakdown of our offer please "
        "see the submission schedule attached.\n"
    )

    # Section 3 — Lump sum
    out.append("\n<!-- 3_LUMP_SUM_STATEMENT -->\n")
    out.append("## OUR LUMP SUM PRICE\n")
    out.append(f"\n**{lump_sum_words}** excluding GST ({lump_sum_figures}).\n")

    # Section 4 — Standard Exclusions (verbatim)
    out.append("\n<!-- 4_STANDARD_EXCLUSIONS -->\n")
    out.append("## STANDARD EXCLUSIONS\n\n")
    for excl in standard_exclusions:
        out.append(f"- {excl}\n")

    # Section 5 — Standard Preliminaries Assumptions (verbatim)
    out.append("\n<!-- 5_PRELIMINARIES_ASSUMPTIONS -->\n")
    out.append("## STANDARD PRELIMINARIES ASSUMPTIONS\n\n")
    for ass in prelim_assumptions:
        out.append(f"- {ass}\n")

    # Section 6 — Detailed Exclusions / Assumptions / Inclusions
    out.append("\n<!-- 6_DETAILED_EXCLUSIONS_ASSUMPTIONS_INCLUSIONS -->\n")
    out.append("## DETAILED EXCLUSIONS, ASSUMPTIONS & INCLUSIONS\n")

    out.append("\n### Exclusions\n\n")
    if detailed_exclusions:
        for x in detailed_exclusions:
            out.append(f"- {x}\n")
    else:
        out.append("- None additional to Standard Exclusions above.\n")

    out.append("\n### Assumptions\n\n")
    if detailed_assumptions:
        for x in detailed_assumptions:
            out.append(f"- {x}\n")
    else:
        out.append("- None additional to Standard Preliminaries Assumptions above.\n")

    out.append("\n### Inclusions\n\n")
    if detailed_inclusions:
        for x in detailed_inclusions:
            out.append(f"- {x}\n")
    else:
        out.append("- Per priced BOQ attached.\n")

    # Section 7 — Validity + sign-off
    out.append("\n<!-- 7_VALIDITY_AND_SIGN_OFF -->\n")
    out.append(f"\nThis quotation is valid for {inputs.validity_days} days from the date of submission.\n")
    out.append(
        "\nWe trust this submission meets your requirements; we welcome any "
        "questions or discussion.\n"
    )
    out.append("\n---\n\n")
    out.append(f"**{inputs.operator_of_record}**\n")
    out.append("Managing Director\n")
    out.append("LF Construction Services Pty Ltd\n")
    return "".join(out)


def _detect_sections(md: str) -> list[str]:
    found: list[str] = []
    for tag in SECTION_HEADERS:
        if f"<!-- {tag} -->" in md:
            found.append(tag)
    return found
