"""Tests for footnote marker-to-definition resolution.

Pure and deterministic: the in-crop pairing tier, the reading-order scan
tier with its coherence acceptance, the Notes-pointer resolution, and the
exception lists. The demand-driven lookup agent is a separate tier with its
own module and is not exercised here.
"""

from __future__ import annotations

from quber.agents.llm_client import FootnoteDef
from quber.core.fusion.footnotes import (
    canonical_marker,
    resolve_footnotes,
    split_leading_marker,
)


def test_canonical_marker_equates_renderings():
    assert canonical_marker("(1)") == canonical_marker("1") == canonical_marker("¹")
    assert canonical_marker("(a)") == canonical_marker("A.")
    assert canonical_marker("†") == "†"
    assert canonical_marker("") == ""


def test_split_leading_marker_shapes():
    assert split_leading_marker("(3) Reflects the transfer.") == ("3", "Reflects the transfer.")
    assert split_leading_marker("2 Includes wireless equipment.") == ("2", "Includes wireless equipment.")
    assert split_leading_marker("a. Amounts are unaudited.") == ("a", "Amounts are unaudited.")
    # A bare short word is how ordinary prose starts, not a letter marker.
    assert split_leading_marker("In the fourth quarter, results improved.") is None
    # A marker with nothing after it defines nothing.
    assert split_leading_marker("(3)") is None


def test_in_crop_pairs_resolve_first():
    out = resolve_footnotes(
        markers=["(3)"],
        footnotes=[FootnoteDef(marker="3", text="Reflects the transfer of obligations.")],
        table_page=12,
    )
    assert [(r.marker, r.text, r.source) for r in out.resolved] == [
        ("(3)", "Reflects the transfer of obligations.", "table")
    ]
    assert out.unresolved == [] and out.unreferenced == []


def test_in_crop_pair_cut_at_page_break_is_completed_by_the_scan():
    # The agent's crop ends at the foot of the page; the parse stitched the
    # footnote's two pages into one line. The longer line is the definition.
    in_crop = (
        "GNMA interest-only securities are recorded at fair value with changes in fair "
        "value recorded in current period earnings. The Company\u2019s GNMA interest-only "
        "securities are considered to be hybrid financial instruments that contain embedded"
    )
    full = (
        "(6) GNMA interest-only securities are recorded at fair value with changes in fair "
        "value recorded in current period earnings. The Company's GNMA interestonly "
        "securities are considered to be hybrid financial instruments that contain embedded "
        "derivatives. As a result, the Company has elected to account for them as hybrid "
        "instruments in their entirety at fair value."
    )
    # The graft attaches the agent's own (cut) text to the table, so the scan
    # universe carries that copy first, unpaged, ahead of the parse's line.
    out = resolve_footnotes(
        markers=["(6)"],
        footnotes=[FootnoteDef(marker="(6)", text=in_crop)],
        table_page=25,
        trailing_lines=[("(5) Includes restricted securities.", 1), ("(6) " + in_crop, 1), (full, 25)],
    )
    assert [(r.text, r.source) for r in out.resolved] == [(full[4:], "scan")]


def test_in_crop_pair_stands_when_the_scan_line_does_not_extend_it():
    # A scan line that merely matches the marker, or that is the same text,
    # never displaces what the agent read off the page image.
    out = resolve_footnotes(
        markers=["(3)"],
        footnotes=[FootnoteDef(marker="3", text="Reflects the transfer of obligations.")],
        table_page=12,
        trailing_lines=[
            ("(3) Reflects the transfer of obligations.", 12),
            ("(3) Something else the scan happens to match.", 12),
        ],
    )
    assert [(r.text, r.source) for r in out.resolved] == [("Reflects the transfer of obligations.", "table")]


def test_scan_accepts_same_page_solitary_numeric():
    out = resolve_footnotes(
        markers=["2"],
        footnotes=[],
        table_page=12,
        trailing_lines=[("2 Includes wireless equipment revenue.", 12)],
    )
    assert out.resolved[0].source == "scan"
    assert out.resolved[0].text == "Includes wireless equipment revenue."


def test_scan_refuses_solitary_numeric_on_distant_page():
    # A lone line-initial digit two pages later proves nothing.
    out = resolve_footnotes(
        markers=["2"],
        footnotes=[],
        table_page=12,
        trailing_lines=[("Some prose.", 13), ("2 Total revenue grew in the quarter.", 14)],
    )
    assert out.resolved == []
    assert out.unresolved == ["2"]


def test_scan_accepts_continuation_block_on_a_later_page():
    # Markers a..c printed overleaf as a run of consecutive marker-opened
    # lines: block coherence accepts them.
    lines = [
        ("a. Amounts are unaudited.", 13),
        ("b. Restated for the segment change.", 13),
        ("c. Excludes discontinued operations.", 13),
    ]
    out = resolve_footnotes(markers=["(b)"], footnotes=[], table_page=12, trailing_lines=lines)
    assert [(r.text, r.source) for r in out.resolved] == [("Restated for the segment change.", "scan")]


def test_scan_accepts_solitary_symbol_on_a_later_page():
    # Only the solitary NUMERIC distant match is refused; a dagger is not a
    # token prose produces.
    out = resolve_footnotes(
        markers=["†"],
        footnotes=[],
        table_page=12,
        trailing_lines=[("† At fair value.", 14)],
    )
    assert out.resolved[0].text == "At fair value."


def test_note_reference_resolves_to_pointer_not_text():
    # The agent judged the marker a section reference; routing follows that
    # judgement, not the marker's wording.
    out = resolve_footnotes(
        markers=["(Note 12)"],
        footnotes=[],
        table_page=30,
        headings=[("Note 12 Employee Benefit Plans", 43)],
        section_keys={canonical_marker("(Note 12)")},
    )
    assert [(r.text, r.source) for r in out.resolved] == [
        ("See Note 12 Employee Benefit Plans (page 43)", "note_pointer")
    ]


def test_note_reference_without_heading_stays_unresolved():
    out = resolve_footnotes(
        markers=["(Note 12)"],
        footnotes=[],
        table_page=30,
        section_keys={canonical_marker("(Note 12)")},
    )
    assert out.resolved == []
    assert out.unresolved == ["(Note 12)"]


def test_unreferenced_marked_definition_is_an_exception_but_unmarked_note_is_not():
    out = resolve_footnotes(
        markers=["1"],
        footnotes=[
            FootnoteDef(marker="1", text="Preliminary."),
            FootnoteDef(marker="2", text="Nothing points here."),
            FootnoteDef(marker="", text="Amounts in millions."),
        ],
        table_page=5,
    )
    assert [r.marker for r in out.resolved] == ["1"]
    assert [d.text for d in out.unreferenced] == ["Nothing points here."]


def test_text_for_matches_by_canonical_key():
    out = resolve_footnotes(
        markers=["(1)"],
        footnotes=[FootnoteDef(marker="1", text="Preliminary.")],
        table_page=5,
    )
    assert out.text_for("¹") == "Preliminary."
    assert out.text_for("(2)") is None


def test_absorb_lookup_resolves_only_matching_markers():
    from quber.core.fusion.footnotes import absorb_lookup

    base = resolve_footnotes(markers=["4", "5"], footnotes=[], table_page=2)
    assert base.unresolved == ["4", "5"]
    out = absorb_lookup(base, [FootnoteDef(marker="(4)", text="At amortized cost.")])
    assert [(r.marker, r.text, r.source) for r in out.resolved] == [("4", "At amortized cost.", "lookup")]
    assert out.unresolved == ["5"]


def test_section_reference_pointer_is_not_note_specific():
    # No vocabulary anywhere: the agent's judgement routes the marker, and
    # the reference as written must open a heading.
    out = resolve_footnotes(
        markers=["(Addendum 3)"],
        footnotes=[],
        table_page=10,
        headings=[("Addendum 3 Regulatory Matters", 12)],
        section_keys={canonical_marker("(Addendum 3)")},
    )
    assert [(r.text, r.source) for r in out.resolved] == [
        ("See Addendum 3 Regulatory Matters (page 12)", "note_pointer")
    ]


def test_section_reference_with_roman_identifier():
    # The reference as written opens the heading; punctuation and spacing
    # differences are ignored, nothing is parsed out of the reference.
    out = resolve_footnotes(
        markers=["Schedule II"],
        footnotes=[],
        table_page=40,
        headings=[("Schedule II — Valuation and Qualifying Accounts", 88)],
        section_keys={canonical_marker("Schedule II")},
    )
    assert [(r.text, r.source) for r in out.resolved] == [
        ("See Schedule II — Valuation and Qualifying Accounts (page 88)", "note_pointer")
    ]


def test_section_reference_without_matching_heading_stays_unresolved():
    # A section reference never falls through to the scan or lookup tiers:
    # it is not a footnote, so with no heading to point at it is flagged,
    # not hunted — the trailing line here must NOT satisfy it.
    out = resolve_footnotes(
        markers=["(Rule 5)"],
        footnotes=[],
        table_page=10,
        trailing_lines=[("5 This line is a real footnote for someone else.", 10)],
        section_keys={canonical_marker("(Rule 5)")},
    )
    assert out.resolved == []
    assert out.unresolved == ["(Rule 5)"]


def test_section_reference_matches_bare_number_heading():
    # 'Note 10' printed on the table; the document heading opens with the
    # bare number '10. Contingencies'. The word-vs-number forms agree on the
    # number, at a digit boundary — '(Note 10)' cannot open '104. Other'.
    out = resolve_footnotes(
        markers=["(Note 10)"],
        footnotes=[],
        table_page=3,
        headings=[("104. Other Items", 2), ("10. Contingencies", 12)],
        section_keys={canonical_marker("(Note 10)")},
    )
    assert [(r.text, r.source) for r in out.resolved] == [("See 10. Contingencies (page 12)", "note_pointer")]


def test_compound_section_reference_points_at_every_named_heading():
    out = resolve_footnotes(
        markers=["Notes 1 and 4"],
        footnotes=[],
        table_page=62,
        headings=[("Note 1 Basis of Presentation", 70), ("Note 4 Impairments", 75)],
        section_keys={canonical_marker("Notes 1 and 4")},
    )
    assert [(r.text, r.source) for r in out.resolved] == [
        ("See Note 1 Basis of Presentation (page 70); See Note 4 Impairments (page 75)", "note_pointer")
    ]


def test_footnote_judged_word_number_marker_falls_back_to_heading_pointer():
    # The agent judged 'Note 18' a footnote; no tier defines it, but a
    # document heading literally opens with it — it resolves as a pointer.
    # A bare digit marker never takes this path.
    out = resolve_footnotes(
        markers=["(Note 18)", "(1)"],
        footnotes=[],
        table_page=22,
        headings=[("NOTE 18. Business Segments", 140), ("1. General", 90)],
    )
    assert [(r.marker, r.source) for r in out.resolved] == [("(Note 18)", "note_pointer")]
    assert out.unresolved == ["(1)"]
