"""Unit tests for GoogleWalletService.

Covers: validate_config, create_class, update_class, create_object,
_build_object_data, get_save_url, _retry_on_failure, get_class_status.
"""

import json
import os
import tempfile
from unittest.mock import MagicMock, patch

import pytest

from app.modules.loyalty.exceptions import (
    GoogleWalletNotConfiguredException,
    WalletIntegrationException,
)
from app.modules.loyalty.services.google_wallet_service import (
    GoogleWalletService,
    _retry_on_failure,
)

MOCK_ISSUER_ID = "1234567890"
MOCK_SA_PATH = "/fake/service_account.json"


def _make_service(issuer_id=MOCK_ISSUER_ID, sa_path=MOCK_SA_PATH):
    """Create a GoogleWalletService with mocked credentials and HTTP client."""
    service = GoogleWalletService()
    service._credentials = MagicMock()
    service._credentials.service_account_email = "test@test.iam.gserviceaccount.com"
    service._http_client = MagicMock()
    return service


def _mock_response(status_code, json_data=None, text="{}"):
    """Create a mock HTTP response."""
    resp = MagicMock()
    resp.status_code = status_code
    resp.text = text
    resp.json.return_value = json_data or {}
    return resp


def _make_program(program_id=1, class_id=None, stamps=False, points=True):
    """Create a mock LoyaltyProgram."""
    program = MagicMock()
    program.id = program_id
    program.google_class_id = class_id
    program.display_name = "Test Rewards"
    program.card_color = "#4285F4"
    program.logo_url = None
    program.hero_image_url = None
    program.merchant.name = "Test Merchant"
    program.is_stamps_enabled = stamps
    program.is_points_enabled = points
    program.stamps_target = 10
    return program


def _make_card(card_id=1, program=None, active=True, google_object_id=None):
    """Create a mock LoyaltyCard."""
    card = MagicMock()
    card.id = card_id
    card.card_number = "LC-ABCD1234"
    card.is_active = active
    card.stamp_count = 3
    card.points_balance = 150
    card.google_object_id = google_object_id
    card.google_object_jwt = None
    card.program = program or _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")
    return card


# ============================================================================
# TestValidateConfig
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestValidateConfig:
    """Tests for validate_config()."""

    @patch("app.modules.loyalty.services.google_wallet_service.requests")
    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_valid_service_account(self, mock_config, mock_requests):
        """Valid service account file results in credentials_valid: True."""
        sa_data = {
            "type": "service_account",
            "project_id": "test-project",
            "private_key": "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n",
            "client_email": "test@test.iam.gserviceaccount.com",
        }
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(sa_data, f)
            sa_path = f.name

        try:
            mock_config.google_issuer_id = MOCK_ISSUER_ID
            mock_config.google_service_account_json = sa_path
            mock_config.default_logo_url = ""
            mock_requests.head.return_value = _mock_response(200)

            service = GoogleWalletService()
            service._credentials = MagicMock()  # Skip real credential loading

            result = service.validate_config()

            assert result["configured"] is True
            assert result["credentials_valid"] is True
            assert result["errors"] == []
        finally:
            os.unlink(sa_path)

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_missing_file(self, mock_config):
        """Missing service account file produces error."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = "/nonexistent/path.json"

        service = GoogleWalletService()
        result = service.validate_config()

        assert result["configured"] is True
        assert result["credentials_valid"] is False
        assert any("not found" in e for e in result["errors"])

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_invalid_json(self, mock_config):
        """Invalid JSON in service account file produces error."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("not valid json {{{")
            sa_path = f.name

        try:
            mock_config.google_issuer_id = MOCK_ISSUER_ID
            mock_config.google_service_account_json = sa_path
            mock_config.default_logo_url = ""

            service = GoogleWalletService()
            result = service.validate_config()

            assert result["credentials_valid"] is False
            assert any("Invalid JSON" in e for e in result["errors"])
        finally:
            os.unlink(sa_path)

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_wrong_credential_type(self, mock_config):
        """Non-service_account type produces error."""
        sa_data = {
            "type": "authorized_user",
            "project_id": "test",
            "private_key": "fake",
            "client_email": "test@test.com",
        }
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(sa_data, f)
            sa_path = f.name

        try:
            mock_config.google_issuer_id = MOCK_ISSUER_ID
            mock_config.google_service_account_json = sa_path
            mock_config.default_logo_url = ""

            service = GoogleWalletService()
            result = service.validate_config()

            assert any("Invalid credential type" in e for e in result["errors"])
        finally:
            os.unlink(sa_path)

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_missing_required_fields(self, mock_config):
        """Missing required fields produce errors."""
        sa_data = {"type": "service_account"}  # Missing project_id, private_key, client_email
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(sa_data, f)
            sa_path = f.name

        try:
            mock_config.google_issuer_id = MOCK_ISSUER_ID
            mock_config.google_service_account_json = sa_path
            mock_config.default_logo_url = ""

            service = GoogleWalletService()
            result = service.validate_config()

            assert result["credentials_valid"] is False
            assert len(result["errors"]) >= 3  # project_id, private_key, client_email
        finally:
            os.unlink(sa_path)


# ============================================================================
# TestCreateClass
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestCreateClass:
    """Tests for create_class()."""

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_201_creates_class(self, mock_config):
        """201 response sets google_class_id and returns class_id."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.default_logo_url = "https://example.com/logo.png"

        service = _make_service()
        service._http_client.post.return_value = _mock_response(201)

        db = MagicMock()
        program = _make_program()

        result = service.create_class(db, program)

        expected_id = f"{MOCK_ISSUER_ID}.loyalty_program_{program.id}"
        assert result == expected_id
        assert program.google_class_id == expected_id
        db.commit.assert_called()

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_409_still_sets_class_id(self, mock_config):
        """409 (already exists) still sets class_id — idempotent."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.default_logo_url = "https://example.com/logo.png"

        service = _make_service()
        service._http_client.post.return_value = _mock_response(409)

        db = MagicMock()
        program = _make_program()

        result = service.create_class(db, program)

        expected_id = f"{MOCK_ISSUER_ID}.loyalty_program_{program.id}"
        assert result == expected_id
        assert program.google_class_id == expected_id

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_500_raises_exception(self, mock_config):
        """500 response raises WalletIntegrationException."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.default_logo_url = "https://example.com/logo.png"

        service = _make_service()
        service._http_client.post.return_value = _mock_response(500, {"error": "internal"})

        db = MagicMock()
        program = _make_program()

        with pytest.raises(WalletIntegrationException):
            service.create_class(db, program)

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_not_configured_raises(self, mock_config):
        """Raises GoogleWalletNotConfiguredException when not configured."""
        mock_config.google_issuer_id = None
        mock_config.google_service_account_json = None

        service = GoogleWalletService()
        db = MagicMock()
        program = _make_program()

        with pytest.raises(GoogleWalletNotConfiguredException):
            service.create_class(db, program)


# ============================================================================
# TestUpdateClass
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestUpdateClass:
    """Tests for update_class()."""

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_200_returns_true(self, mock_config):
        """200 response returns True."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH

        service = _make_service()
        service._http_client.patch.return_value = _mock_response(200)

        db = MagicMock()
        program = _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")

        assert service.update_class(db, program) is True

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_non_200_returns_false(self, mock_config):
        """Non-200 response returns False and logs error."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH

        service = _make_service()
        service._http_client.patch.return_value = _mock_response(500)

        db = MagicMock()
        program = _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")

        assert service.update_class(db, program) is False

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_no_class_id_returns_false(self, mock_config):
        """Returns False early when program has no google_class_id."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH

        service = _make_service()
        db = MagicMock()
        program = _make_program(class_id=None)

        assert service.update_class(db, program) is False
        service._http_client.patch.assert_not_called()


# ============================================================================
# TestCreateObject
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestCreateObject:
    """Tests for create_object()."""

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_auto_creates_class_when_none(self, mock_config):
        """Auto-creates class when program.google_class_id is None."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.default_logo_url = "https://example.com/logo.png"

        service = _make_service()
        # First call creates class (201), second creates object (201)
        service._http_client.post.side_effect = [
            _mock_response(201),  # create_class
            _mock_response(201),  # create_object
        ]

        db = MagicMock()
        program = _make_program(class_id=None)
        card = _make_card(program=program)

        result = service.create_object(db, card)

        assert result == f"{MOCK_ISSUER_ID}.loyalty_card_{card.id}"
        assert service._http_client.post.call_count == 2

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    @patch("app.modules.loyalty.services.google_wallet_service.time")
    def test_500_retried_via_decorator(self, mock_time, mock_config):
        """500 error triggers retry via _retry_on_failure decorator."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH

        service = _make_service()
        program = _make_program(class_id=f"{MOCK_ISSUER_ID}.loyalty_program_1")
        card = _make_card(program=program)

        # First call 500, second 201
        service._http_client.post.side_effect = [
            _mock_response(500, {"error": "transient"}),
            _mock_response(201),
        ]

        db = MagicMock()
        result = service.create_object(db, card)

        assert result == f"{MOCK_ISSUER_ID}.loyalty_card_{card.id}"
        assert service._http_client.post.call_count == 2


# ============================================================================
# TestBuildObjectData
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestBuildObjectData:
    """Tests for _build_object_data()."""

    def test_stamps_program(self):
        """Stamps program includes loyaltyPoints + secondaryLoyaltyPoints."""
        service = GoogleWalletService()
        program = _make_program(stamps=True, points=False)
        program.stamps_target = 10
        card = _make_card(program=program)
        card.stamp_count = 3

        data = service._build_object_data(card, "test_obj_1")

        assert data["loyaltyPoints"]["label"] == "Stamps"
        assert data["loyaltyPoints"]["balance"]["int"] == 3
        assert "secondaryLoyaltyPoints" in data
        assert data["secondaryLoyaltyPoints"]["balance"]["int"] == 10

    def test_points_program(self):
        """Points program includes loyaltyPoints only, no secondary."""
        service = GoogleWalletService()
        program = _make_program(stamps=False, points=True)
        card = _make_card(program=program)
        card.points_balance = 150

        data = service._build_object_data(card, "test_obj_1")

        assert data["loyaltyPoints"]["label"] == "Points"
        assert data["loyaltyPoints"]["balance"]["int"] == 150
        assert "secondaryLoyaltyPoints" not in data

    def test_inactive_card(self):
        """Inactive card has state INACTIVE."""
        service = GoogleWalletService()
        card = _make_card(active=False)

        data = service._build_object_data(card, "test_obj_1")

        assert data["state"] == "INACTIVE"

    def test_barcode_strips_dashes(self):
        """Barcode value has dashes removed from card_number."""
        service = GoogleWalletService()
        card = _make_card()
        card.card_number = "LC-ABCD-1234"

        data = service._build_object_data(card, "test_obj_1")

        assert data["barcode"]["value"] == "LCABCD1234"
        assert data["barcode"]["alternateText"] == "LC-ABCD-1234"


# ============================================================================
# TestGetSaveUrl
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestGetSaveUrl:
    """Tests for get_save_url()."""

    @patch("jwt.encode", return_value="test_token")
    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_existing_object_references_by_id(self, mock_config, mock_jwt_encode):
        """When object exists, JWT payload references by ID only."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.google_wallet_origins = ["https://example.com"]

        service = _make_service()
        service._private_key = "fake-key"

        card = _make_card(google_object_id=f"{MOCK_ISSUER_ID}.loyalty_card_1")
        db = MagicMock()

        url = service.get_save_url(db, card)

        assert url == "https://pay.google.com/gp/v/save/test_token"
        claims = mock_jwt_encode.call_args[0][0]
        assert claims["payload"]["loyaltyObjects"][0] == {"id": card.google_object_id}

    @patch("jwt.encode", return_value="fat_token")
    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_object_creation_fails_uses_fat_jwt(self, mock_config, mock_jwt_encode):
        """When object creation fails, JWT embeds full object data."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.google_wallet_origins = []

        service = _make_service()
        service._private_key = "fake-key"
        # create_object will fail
        service._http_client.post.return_value = _mock_response(500, {"error": "fail"})

        card = _make_card(google_object_id=None)
        db = MagicMock()

        url = service.get_save_url(db, card)

        assert "fat_token" in url
        claims = mock_jwt_encode.call_args[0][0]
        obj = claims["payload"]["loyaltyObjects"][0]
        assert "classId" in obj

    @patch("jwt.encode", return_value="token")
    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_jwt_claims_structure(self, mock_config, mock_jwt_encode):
        """JWT claims include iss, aud, origins, typ, exp."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH
        mock_config.google_wallet_origins = ["https://app.example.com"]

        service = _make_service()
        service._private_key = "fake-key"

        card = _make_card(google_object_id=f"{MOCK_ISSUER_ID}.loyalty_card_1")
        db = MagicMock()

        service.get_save_url(db, card)

        claims = mock_jwt_encode.call_args[0][0]
        assert claims["iss"] == "test@test.iam.gserviceaccount.com"
        assert claims["aud"] == "google"
        assert claims["origins"] == ["https://app.example.com"]
        assert claims["typ"] == "savetowallet"
        assert "iat" in claims
        assert "exp" in claims


# ============================================================================
# TestRetryDecorator
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestRetryDecorator:
    """Tests for _retry_on_failure decorator."""

    @patch("app.modules.loyalty.services.google_wallet_service.time")
    def test_succeeds_first_try(self, mock_time):
        """No retry when function succeeds on first try."""
        call_count = 0

        @_retry_on_failure
        def always_succeed():
            nonlocal call_count
            call_count += 1
            return "ok"

        result = always_succeed()

        assert result == "ok"
        assert call_count == 1
        mock_time.sleep.assert_not_called()

    @patch("app.modules.loyalty.services.google_wallet_service.time")
    def test_retries_on_500_then_succeeds(self, mock_time):
        """Retries on 500 error and succeeds on second attempt."""
        call_count = 0

        @_retry_on_failure
        def fail_then_succeed():
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise WalletIntegrationException("google", "Server error 500 ")
            return "ok"

        result = fail_then_succeed()

        assert result == "ok"
        assert call_count == 2
        mock_time.sleep.assert_called_once()

    @patch("app.modules.loyalty.services.google_wallet_service.time")
    def test_404_not_retried(self, mock_time):
        """404 error is not retried — immediate failure."""
        call_count = 0

        @_retry_on_failure
        def always_404():
            nonlocal call_count
            call_count += 1
            raise WalletIntegrationException("google", "Not found: 404 error")

        with pytest.raises(WalletIntegrationException):
            always_404()

        assert call_count == 1
        mock_time.sleep.assert_not_called()


# ============================================================================
# TestGetClassStatus
# ============================================================================


@pytest.mark.unit
@pytest.mark.loyalty
class TestGetClassStatus:
    """Tests for get_class_status()."""

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_200_returns_status(self, mock_config):
        """200 response returns dict with review_status."""
        mock_config.google_issuer_id = MOCK_ISSUER_ID
        mock_config.google_service_account_json = MOCK_SA_PATH

        service = _make_service()
        service._http_client.get.return_value = _mock_response(
            200,
            {"reviewStatus": "APPROVED", "programName": "Test Rewards"},
        )

        result = service.get_class_status("test_class_id")

        assert result is not None
        assert result["review_status"] == "APPROVED"
        assert result["class_id"] == "test_class_id"

    @patch("app.modules.loyalty.services.google_wallet_service.config")
    def test_not_configured_returns_none(self, mock_config):
        """Returns None when Google Wallet is not configured."""
        mock_config.google_issuer_id = None
        mock_config.google_service_account_json = None

        service = GoogleWalletService()
        result = service.get_class_status("test_class_id")

        assert result is None
