"""
Bank Details (tenant EFT/wire payment instructions) tests.

GET/PUT /settings/bank-details return the full, decrypted account number —
the cemetery admin needs to see and verify it, and the same decrypted value
flows into the contract PDF/email so the purchaser can actually complete an
EFT/wire transfer (BankDetailsService.get_decrypted_for_tenant is the read
path for all of these). Masking (get_masked_for_tenant/to_masked) still
exists for exactly one caller: the public, unauthenticated
GET /public/contracts/{subdomain}/{contract_number}/pdf endpoint, whose
contract_number is sequential/guessable with no auth — see the module
docstring in bank_details_service.py.

Covers:
  - BankDetailsUpsertRequest schema validation (institution/transit/account
    number patterns, account_type, angle-bracket rejection)
  - BankDetailsService.upsert: create-then-update-in-place, exactly one row
    per tenant
  - Encrypt/decrypt round trip via the service
  - GET/PUT return the full decrypted account number
  - get_masked_for_tenant still masks (for the public PDF endpoint only)
  - Router role gate (ADMINISTRATOR floor — stricter than most /settings
    endpoints, which allow STAFF/VIEW_ONLY)
  - Multi-tenant isolation
"""
import json
from unittest.mock import patch
from uuid import uuid4

import pytest
import pytest_asyncio
from cryptography.fernet import Fernet
from httpx import AsyncClient
from pydantic import ValidationError
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.settings.schemas.requests import BankDetailsUpsertRequest

BASE = "/api/v1/settings/bank-details"

_TEST_FERNET_KEY = Fernet.generate_key().decode()


@pytest.fixture(autouse=True)
def _bank_details_encryption_key():
    """Local/dev .env leaves BANK_DETAILS_ENCRYPTION_KEY blank — it's only
    enforced fail-fast in production (config.py's
    _validate_bank_details_encryption_key). Without a key,
    encrypt_value/decrypt_value (src/core/encryption.py) raise
    RuntimeError, so every test in this module patches in a valid Fernet
    key for its duration."""
    from src.core.config import settings as real_settings

    with patch.object(real_settings, "BANK_DETAILS_ENCRYPTION_KEY", _TEST_FERNET_KEY):
        yield


def _payload(**overrides) -> dict:
    data = {
        "account_holder_name": "Riverside Cemetery Corp",
        "bank_name": "Test National Bank",
        "institution_number": "001",
        "transit_number": "12345",
        "account_number": "1234567890",
        "account_type": "chequing",
        "currency": "CAD",
        "branch_address": "123 Bank St, Toronto, ON",
    }
    data.update(overrides)
    return data


# ---------------------------------------------------------------------------
# 1. Schema validation (synchronous — no DB)
# ---------------------------------------------------------------------------

class TestBankDetailsUpsertRequestSchema:
    def test_valid_payload_parses(self):
        req = BankDetailsUpsertRequest(**_payload())
        assert req.account_number == "1234567890"
        assert req.currency == "CAD"

    def test_institution_number_wrong_length_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(institution_number="12"))
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(institution_number="1234"))

    def test_institution_number_non_numeric_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(institution_number="abc"))

    def test_transit_number_wrong_length_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(transit_number="1234"))
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(transit_number="123456"))

    def test_account_number_too_short_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(account_number="123456"))  # 6 digits

    def test_account_number_too_long_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(account_number="1" * 13))  # 13 digits

    def test_account_number_non_numeric_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(account_number="12345abc"))

    def test_account_number_exactly_7_and_12_digits_pass(self):
        req7 = BankDetailsUpsertRequest(**_payload(account_number="1234567"))
        req12 = BankDetailsUpsertRequest(**_payload(account_number="123456789012"))
        assert req7.account_number == "1234567"
        assert req12.account_number == "123456789012"

    def test_invalid_account_type_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(account_type="crypto"))

    def test_angle_brackets_rejected_in_bank_name(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(bank_name='<img src="http://example.invalid/x"/>'))

    def test_angle_brackets_rejected_in_account_holder_name(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(account_holder_name="Jane <b>Doe</b>"))

    def test_angle_brackets_rejected_in_branch_address(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(branch_address="123 Main St <script>alert(1)</script>"))

    def test_extra_fields_rejected(self):
        with pytest.raises(ValidationError):
            BankDetailsUpsertRequest(**_payload(tenant_id=str(uuid4())))


# ---------------------------------------------------------------------------
# 2. Service-layer tests (real DB via db_session)
# ---------------------------------------------------------------------------

@pytest_asyncio.fixture
async def bank_tenant(db_session: AsyncSession):
    from src.apps.tenants.models.account import Account

    account = Account(
        organization_name="Bank Test Cemetery",
        subdomain=f"bank-test-{uuid4().hex[:8]}",
        contact_email=f"admin-{uuid4().hex[:6]}@banktest.com",
        plan="professional",
        status="active",
    )
    db_session.add(account)
    await db_session.flush()
    return account


class TestBankDetailsServiceUpsert:
    @pytest.mark.asyncio
    async def test_upsert_creates_row_when_none_exists(self, db_session, bank_tenant):
        from src.apps.settings.models.bank_details import BankDetails
        from src.apps.settings.services.bank_details_service import BankDetailsService

        payload = BankDetailsUpsertRequest(**_payload())
        record = await BankDetailsService.upsert(db_session, bank_tenant.id, payload)

        assert record.id is not None
        assert record.account_number_last4 == "7890"

        count = (
            await db_session.execute(
                select(func.count()).select_from(BankDetails).where(BankDetails.tenant_id == bank_tenant.id)
            )
        ).scalar_one()
        assert count == 1

    @pytest.mark.asyncio
    async def test_upsert_updates_in_place_exactly_one_row(self, db_session, bank_tenant):
        from src.apps.settings.models.bank_details import BankDetails
        from src.apps.settings.services.bank_details_service import BankDetailsService

        first = await BankDetailsService.upsert(
            db_session, bank_tenant.id, BankDetailsUpsertRequest(**_payload())
        )
        second = await BankDetailsService.upsert(
            db_session,
            bank_tenant.id,
            BankDetailsUpsertRequest(**_payload(bank_name="Updated Bank", account_number="9999999999")),
        )

        assert first.id == second.id  # same row updated in place, not a new one
        assert second.bank_name == "Updated Bank"
        assert second.account_number_last4 == "9999"

        count = (
            await db_session.execute(
                select(func.count()).select_from(BankDetails).where(BankDetails.tenant_id == bank_tenant.id)
            )
        ).scalar_one()
        assert count == 1

    @pytest.mark.asyncio
    async def test_encrypt_decrypt_round_trip(self, db_session, bank_tenant):
        from src.apps.settings.services.bank_details_service import BankDetailsService

        known_account_number = "4567891230"
        payload = BankDetailsUpsertRequest(**_payload(account_number=known_account_number))
        await BankDetailsService.upsert(db_session, bank_tenant.id, payload)

        decrypted = await BankDetailsService.get_decrypted_for_tenant(db_session, bank_tenant.id)
        assert decrypted is not None
        assert decrypted["account_number"] == known_account_number

    @pytest.mark.asyncio
    async def test_get_masked_for_tenant_never_exposes_full_number(self, db_session, bank_tenant):
        """get_masked_for_tenant is used only by the public, unauthenticated
        contract-PDF endpoint — it must still mask, even though the
        authenticated Settings GET/PUT now return the full number."""
        from src.apps.settings.services.bank_details_service import BankDetailsService

        known_account_number = "1122334455"
        payload = BankDetailsUpsertRequest(**_payload(account_number=known_account_number))
        await BankDetailsService.upsert(db_session, bank_tenant.id, payload)

        masked = await BankDetailsService.get_masked_for_tenant(db_session, bank_tenant.id)
        assert masked is not None
        assert masked["account_number"] == "****4455"
        assert BankDetailsService.to_masked("4455") == "****4455"
        assert masked["account_number"] != known_account_number

    @pytest.mark.asyncio
    async def test_get_masked_for_tenant_returns_none_when_not_configured(self, db_session, bank_tenant):
        from src.apps.settings.services.bank_details_service import BankDetailsService

        result = await BankDetailsService.get_masked_for_tenant(db_session, bank_tenant.id)
        assert result is None

    @pytest.mark.asyncio
    async def test_get_decrypted_for_tenant_returns_none_when_not_configured(self, db_session, bank_tenant):
        from src.apps.settings.services.bank_details_service import BankDetailsService

        result = await BankDetailsService.get_decrypted_for_tenant(db_session, bank_tenant.id)
        assert result is None


# ---------------------------------------------------------------------------
# 3. Router tests (real DB via AsyncClient)
# ---------------------------------------------------------------------------

def _token(user, account) -> str:
    from src.core.security import build_token_payload, create_access_token

    return create_access_token(build_token_payload(user, account))


async def _make_user(db_session, account, role, email):
    from src.apps.auth.models.user import User
    from src.core.security import hash_password

    user = User(
        tenant_id=account.id,
        email=email,
        password_hash=hash_password("TestPassword123!"),
        first_name=role,
        last_name="User",
        role=role,
        status="active",
    )
    db_session.add(user)
    await db_session.flush()
    return user


@pytest_asyncio.fixture
async def bd_admin_user(db_session, bank_tenant):
    return await _make_user(db_session, bank_tenant, "administrator", f"admin-{uuid4().hex[:6]}@banktest.com")


@pytest_asyncio.fixture
async def bd_staff_user(db_session, bank_tenant):
    return await _make_user(db_session, bank_tenant, "staff", f"staff-{uuid4().hex[:6]}@banktest.com")


@pytest_asyncio.fixture
async def bd_viewonly_user(db_session, bank_tenant):
    return await _make_user(db_session, bank_tenant, "view_only", f"vo-{uuid4().hex[:6]}@banktest.com")


@pytest_asyncio.fixture
async def bd_admin_headers(bd_admin_user, bank_tenant):
    return {
        "Authorization": f"Bearer {_token(bd_admin_user, bank_tenant)}",
        "X-Tenant-ID": str(bank_tenant.id),
    }


@pytest_asyncio.fixture
async def bd_staff_headers(bd_staff_user, bank_tenant):
    return {
        "Authorization": f"Bearer {_token(bd_staff_user, bank_tenant)}",
        "X-Tenant-ID": str(bank_tenant.id),
    }


@pytest_asyncio.fixture
async def bd_viewonly_headers(bd_viewonly_user, bank_tenant):
    return {
        "Authorization": f"Bearer {_token(bd_viewonly_user, bank_tenant)}",
        "X-Tenant-ID": str(bank_tenant.id),
    }


# 3a. GET — not configured / after PUT

@pytest.mark.asyncio
async def test_get_requires_auth(client: AsyncClient, bank_tenant):
    resp = await client.get(BASE, headers={"X-Tenant-ID": str(bank_tenant.id)})
    assert resp.status_code == 401


@pytest.mark.asyncio
async def test_get_returns_null_when_not_configured(client: AsyncClient, bd_admin_headers):
    resp = await client.get(BASE, headers=bd_admin_headers)
    assert resp.status_code == 200
    body = resp.json()
    assert body["success"] is True
    assert body["data"] is None


@pytest.mark.asyncio
async def test_put_then_get_returns_full_account_number(client: AsyncClient, bd_admin_headers):
    put_resp = await client.put(BASE, headers=bd_admin_headers, json=_payload(account_number="6543219870"))
    assert put_resp.status_code == 200, put_resp.text
    put_data = put_resp.json()["data"]
    assert put_data["account_number"] == "6543219870"
    assert "account_number_masked" not in put_data
    assert {
        "id", "tenant_id", "account_holder_name", "bank_name", "institution_number",
        "transit_number", "account_number", "account_type", "currency",
        "branch_address", "created_at", "updated_at",
    } <= set(put_data.keys())

    get_resp = await client.get(BASE, headers=bd_admin_headers)
    assert get_resp.status_code == 200
    get_data = get_resp.json()["data"]
    assert get_data["account_number"] == "6543219870"


# 3b. PUT validation (422)

@pytest.mark.asyncio
@pytest.mark.parametrize(
    "overrides",
    [
        {"institution_number": "12"},
        {"institution_number": "abcd"},
        {"transit_number": "1234"},
        {"transit_number": "123456"},
        {"account_number": "123456"},   # too short (6 digits)
        {"account_number": "1" * 13},   # too long (13 digits)
        {"account_type": "crypto"},
    ],
    ids=[
        "institution_number_too_short",
        "institution_number_non_numeric",
        "transit_number_too_short",
        "transit_number_too_long",
        "account_number_too_short",
        "account_number_too_long",
        "invalid_account_type",
    ],
)
async def test_put_invalid_payload_returns_422(client: AsyncClient, bd_admin_headers, overrides):
    resp = await client.put(BASE, headers=bd_admin_headers, json=_payload(**overrides))
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_put_angle_brackets_returns_422_with_clear_error(client: AsyncClient, bd_admin_headers):
    resp = await client.put(
        BASE,
        headers=bd_admin_headers,
        json=_payload(bank_name='<img src="http://example.invalid/x"/>'),
    )
    assert resp.status_code == 422
    body = resp.json()
    # error message should mention angle brackets, not just a generic pattern failure
    raw = json.dumps(body).lower()
    assert "angle bracket" in raw


# 3c. Role gate — ADMINISTRATOR floor (stricter than most /settings endpoints)

@pytest.mark.asyncio
async def test_get_staff_role_forbidden(client: AsyncClient, bd_staff_headers):
    resp = await client.get(BASE, headers=bd_staff_headers)
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_get_view_only_role_forbidden(client: AsyncClient, bd_viewonly_headers):
    resp = await client.get(BASE, headers=bd_viewonly_headers)
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_put_staff_role_forbidden(client: AsyncClient, bd_staff_headers):
    resp = await client.put(BASE, headers=bd_staff_headers, json=_payload())
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_put_view_only_role_forbidden(client: AsyncClient, bd_viewonly_headers):
    resp = await client.put(BASE, headers=bd_viewonly_headers, json=_payload())
    assert resp.status_code == 403


# 3d. GET/PUT response always carries the real account number

@pytest.mark.asyncio
async def test_get_and_put_response_json_contains_plaintext_account_number(
    client: AsyncClient, bd_admin_headers
):
    """The admin needs the real number to verify it, and the purchaser needs
    it (via the contract PDF/email, sourced from this same decrypted read
    path) to actually complete an EFT/wire transfer — so it must round-trip
    in full, not masked."""
    known_account_number = "8887776665"
    put_resp = await client.put(BASE, headers=bd_admin_headers, json=_payload(account_number=known_account_number))
    assert put_resp.status_code == 200

    get_resp = await client.get(BASE, headers=bd_admin_headers)
    assert get_resp.status_code == 200

    assert known_account_number in json.dumps(put_resp.json())
    assert known_account_number in json.dumps(get_resp.json())


# 3e. Multi-tenant isolation

@pytest_asyncio.fixture
async def other_bank_tenant(db_session):
    from src.apps.tenants.models.account import Account

    account = Account(
        organization_name="Other Bank Cemetery",
        subdomain=f"other-bank-{uuid4().hex[:8]}",
        contact_email=f"admin-{uuid4().hex[:6]}@otherbank.com",
        plan="professional",
        status="active",
    )
    db_session.add(account)
    await db_session.flush()
    return account


@pytest_asyncio.fixture
async def other_bd_admin_headers(db_session, other_bank_tenant):
    user = await _make_user(db_session, other_bank_tenant, "administrator", f"admin-{uuid4().hex[:6]}@otherbank.com")
    return {
        "Authorization": f"Bearer {_token(user, other_bank_tenant)}",
        "X-Tenant-ID": str(other_bank_tenant.id),
    }


@pytest.mark.asyncio
async def test_tenant_isolation_bank_details_not_visible_cross_tenant(
    client: AsyncClient, bd_admin_headers, other_bd_admin_headers
):
    """Tenant A configures bank details; tenant B's administrator must see
    data: null, never tenant A's row."""
    put_resp = await client.put(BASE, headers=bd_admin_headers, json=_payload(account_number="1231231234"))
    assert put_resp.status_code == 200

    other_get = await client.get(BASE, headers=other_bd_admin_headers)
    assert other_get.status_code == 200
    assert other_get.json()["data"] is None
