"""Field extraction from recognised text.

The rule under test throughout: **never guess**. A missing field must be absent,
not approximated. An invented CNIC that looks plausible is worse than a missing
one, because nobody re-checks a field that is already filled in.
"""

from __future__ import annotations

import pytest

from app.ocr.extraction import (
    extract_all,
    extract_amounts,
    extract_cnic,
    extract_dates,
    extract_email,
    extract_iban,
    extract_mobile,
    extract_passport,
    extract_tax_year,
)


class TestCnic:
    @pytest.mark.parametrize(
        "text",
        [
            "CNIC: 35202-1234567-1",
            "Identity Number 35202 1234567 1",
            "3520212345671",  # OCR often drops the dashes
        ],
    )
    def test_it_finds_and_normalises_a_cnic(self, text):
        found = extract_cnic(text)

        assert found is not None
        assert found.value == "35202-1234567-1"

    def test_it_keeps_the_raw_match_for_review(self):
        # An approval screen showing only the cleaned value hides the mistake
        # it exists to catch.
        found = extract_cnic("CNIC 35202 1234567 1")

        assert found.raw == "35202 1234567 1"

    def test_an_impossible_province_code_is_rejected(self):
        # Province codes run 1-8; a 9 is a misread, not a citizen.
        assert extract_cnic("95202-1234567-1") is None

    def test_a_repeated_digit_run_is_rejected(self):
        assert extract_cnic("1111111111111") is None

    def test_a_longer_number_is_not_mistaken_for_a_cnic(self):
        # Word boundaries stop a 13-digit window inside a 16-digit reference.
        assert extract_cnic("Reference 1234567890123456") is None

    def test_absent_means_none_not_a_guess(self):
        assert extract_cnic("No identity document was attached.") is None


class TestIban:
    def test_it_finds_a_pakistani_iban(self):
        found = extract_iban("Account: PK36SCBL0000001123456702")

        assert found.value == "PK36SCBL0000001123456702"

    def test_it_upper_cases(self):
        assert extract_iban("pk36scbl0000001123456702").value == "PK36SCBL0000001123456702"

    def test_a_foreign_iban_is_not_matched(self):
        # This practice files Pakistani accounts; a loose matcher would pick up
        # any IBAN-shaped string and file it as the client's.
        assert extract_iban("GB29NWBK60161331926819") is None


class TestMobile:
    @pytest.mark.parametrize(
        "text",
        ["03001234567", "0300-1234567", "+92 300 1234567", "92-300-1234567"],
    )
    def test_it_normalises_every_common_form(self, text):
        assert extract_mobile(text).value == "03001234567"

    def test_a_landline_is_not_a_mobile(self):
        # Mobiles start 03; 021 is Karachi's landline code.
        assert extract_mobile("021-35678901") is None


class TestEmail:
    def test_it_finds_and_lower_cases(self):
        assert extract_email("Contact: Ali.Khan@Example.COM").value == "ali.khan@example.com"

    def test_absent_means_none(self):
        assert extract_email("no address here") is None


class TestPassport:
    def test_it_finds_a_passport_number(self):
        assert extract_passport("Passport AB1234567").value == "AB1234567"

    def test_its_confidence_is_lower_than_a_cnic(self):
        # Two letters and seven digits is a common shape — a reference number
        # can match by coincidence, and the score should say so.
        assert extract_passport("AB1234567").confidence < extract_cnic("35202-1234567-1").confidence


class TestDates:
    def test_it_parses_day_first(self):
        # Every Pakistani document is day-first; assuming month-first silently
        # swaps the two for any day under 13.
        found = extract_dates("Issued 05/03/2024")

        assert found[0].value == "2024-03-05"

    @pytest.mark.parametrize("separator", ["/", "-", "."])
    def test_it_accepts_each_separator(self, separator):
        assert extract_dates(f"01{separator}02{separator}2024")[0].value == "2024-02-01"

    def test_an_impossible_date_is_discarded(self):
        # 31 February is a misread, not a date to salvage.
        assert extract_dates("31/02/2024") == []

    def test_it_returns_all_dates_rather_than_choosing(self):
        # Which is issue and which is expiry depends on the document type, and
        # that decision belongs with something that knows what it is reading.
        found = extract_dates("Issued 01/01/2024 Expires 31/12/2029")

        assert [f.value for f in found] == ["2024-01-01", "2029-12-31"]


class TestTaxYear:
    def test_it_finds_a_plausible_year(self):
        assert extract_tax_year("Tax Year 2024").value == "2024"

    def test_an_out_of_range_year_is_ignored(self):
        # Otherwise any four-digit number satisfies the pattern.
        assert extract_tax_year("Reference 1998 / 3050") is None


class TestAmounts:
    def test_it_requires_an_explicit_currency_marker(self):
        # An unmarked number is as likely a year or a page number, and treating
        # it as money is how a wrong figure reaches an invoice.
        assert extract_amounts("Total 45000") == []
        assert extract_amounts("Total Rs. 45,000")[0].value == "45000"

    def test_it_orders_by_magnitude(self):
        found = extract_amounts("Rs 500 paid against PKR 45,000 due")

        assert [f.value for f in found] == ["45000", "500"]

    def test_it_keeps_decimals(self):
        assert extract_amounts("Rs 1,234.56")[0].value == "1234.56"


class TestExtractAll:
    def test_absent_fields_are_omitted_not_nulled(self):
        # A key with None invites a caller to write that None over a value the
        # CMS already holds.
        result = extract_all("Just some prose with nothing in it.")

        assert result == {}

    def test_it_collects_what_is_present(self):
        result = extract_all(
            "Muhammad Ali, CNIC 35202-1234567-1, mobile 0300-1234567, "
            "ali@example.com, Tax Year 2024, Rs. 50,000"
        )

        assert result["cnic"]["value"] == "35202-1234567-1"
        assert result["mobile"]["value"] == "03001234567"
        assert result["email"]["value"] == "ali@example.com"
        assert result["tax_year"]["value"] == "2024"
        assert result["amounts"][0]["value"] == "50000"

    def test_every_field_carries_its_confidence(self):
        result = extract_all("CNIC 35202-1234567-1")

        assert 0.0 < result["cnic"]["confidence"] <= 1.0


class TestTheSeparatorIsWhateverTheRecogniserSaw:
    """A dash printed on a card comes back as several different characters.

    Found on a real pair: the same person's card was sent front and back
    together. The front read `35202-7654321-3` and the back read
    `35202-7654321.3`. The back was refused, so it matched no client — and
    because the number is also the second marker `cnic_back` needs, it failed to
    classify as well. One misread character cost both, on a card whose front had
    already named the person.
    """

    def test_a_dot_where_a_dash_should_be(self):
        # The exact string the back of the card produced.
        assert extract_cnic("35202-7654321.3").value == "35202-7654321-3"

    def test_every_punctuation_the_recogniser_offers(self):
        for written in (
            "35202-7654321-3",
            "35202.7654321.3",
            "35202 7654321 3",
            "35202·7654321·3",
            "352027654321 3",
            "3520276543213",
        ):
            found = extract_cnic(written)

            assert found is not None, written
            assert found.value == "35202-7654321-3", written

    def test_a_card_punctuated_number_wins_over_one_in_a_column_of_figures(self):
        """The cost of accepting a dot, kept small.

        `12345.6789012 3` in a table of amounts has the shape of an identity
        number. Cards print dashes and totals do not, so the dashed match is
        preferred — and a wrong client is worse than none, because it files a
        document against a real person with nothing to do with it.
        """
        text = "Rs. 12345.6789012 3 carried forward\nIdentity Number 35202-7654321-3"

        assert extract_cnic(text).value == "35202-7654321-3"

    def test_a_number_with_no_separators_is_still_read(self):
        # A preference, not a requirement: cards do come back unpunctuated, and
        # refusing those would lose more than it protects.
        assert extract_cnic("Identity 3520276543213").value == "35202-7654321-3"

    def test_an_impossible_province_is_still_refused(self):
        # The looser separator must not loosen the validity rule with it.
        assert extract_cnic("95202.7654321.3") is None
