"""
Contrato de la configuración editable de un dominio: `PATCH /domains/{id}` (FR-057).

Lo que se fija acá es la regla que relaciona los dos números: la reserva manual
no puede superar al presupuesto diario. Sin ella se puede guardar un dominio con
300 consultas reservadas sobre un cupo de 200, y a partir de ahí el ciclo
automático tiene un cupo negativo que nadie declaró y que sólo se descubre
cuando un lote termina parcial sin motivo visible.

La otra mitad del contrato es lo que **no** se puede editar por acá. El estado de
acceso y la forma de la propiedad describen algo que vive en Search Console:
escribirlos desde la API permitiría declarar operativo un dominio que Google
nunca confirmó, y todo lo que el producto afirma se apoya en esa confirmación.
"""

import pytest
from rest_framework.test import APIClient

from apps.accounts.models import ApiKey
from apps.domains.models import AccessState
from apps.domains.services import deactivate_domain
from apps.jobs.budget import budget_for
from tests.factories import create_account, create_credential, create_domain

pytestmark = pytest.mark.django_db


@pytest.fixture
def account():
    account = create_account()
    create_credential(account)
    return account


@pytest.fixture
def domain(account):
    return create_domain(account)


@pytest.fixture
def client(account):
    api = APIClient()
    _, plain_text = ApiKey.issue(account=account, name='pipeline')
    api.credentials(HTTP_AUTHORIZATION=f'Api-Key {plain_text}')
    return api


def patch(client, domain, body):
    return client.patch(f'/api/v1/domains/{domain.id}', body, format='json')


class TestWhatCanBeEdited:
    def test_the_notifications_switch_turns_off_and_on(self, client, domain):
        assert domain.notifications_enabled is True

        response = patch(client, domain, {'notifications_enabled': False})

        assert response.status_code == 200
        assert response.json()['notifications_enabled'] is False
        domain.refresh_from_db()
        assert domain.notifications_enabled is False

    def test_the_two_quotas_are_edited_together(self, client, domain):
        response = patch(client, domain, {'daily_inspection_budget': 1500, 'manual_reserve': 200})

        assert response.status_code == 200
        domain.refresh_from_db()
        assert domain.daily_inspection_budget == 1500
        assert domain.manual_reserve == 200

    def test_the_response_is_the_whole_domain_and_not_only_what_changed(self, client, domain):
        """
        Quien integra guarda la respuesta tal cual, sin fusionarla con lo que tenía.

        Devolver un parche obligaría a cada cliente a saber cuál de sus campos
        quedó viejo, que es exactamente el trabajo que el contrato tiene que
        ahorrarle.
        """
        data = patch(client, domain, {'notifications_enabled': False}).json()

        assert data['hostname'] == domain.hostname
        assert data['property_uri'] == domain.property_uri
        assert data['access_state'] == domain.access_state
        assert data['daily_inspection_budget'] == domain.daily_inspection_budget

    def test_a_single_field_is_validated_against_what_was_already_stored(self, client, domain):
        """
        Un `PATCH` parcial es parcial de verdad: lo ausente conserva su valor.

        La reserva se compara contra el presupuesto que el dominio ya tiene, no
        contra un valor por omisión: si se comparara contra otra cosa, subir sólo
        la reserva pasaría o fallaría según algo que el cliente no mandó.
        """
        response = patch(client, domain, {'manual_reserve': 400})

        assert response.status_code == 200
        domain.refresh_from_db()
        assert domain.manual_reserve == 400
        assert domain.daily_inspection_budget == 1000

    def test_the_new_limits_do_not_rewrite_the_budget_of_the_day_in_progress(self, client, domain):
        """
        Lo guardado rige desde el próximo ciclo, no sobre el consumo ya ocurrido.

        El presupuesto del día copió sus límites al crearse. Reescribirlos
        cambiaría el denominador de un gasto que ya pasó, y el medidor de la
        ficha pasaría a explicar el día de hoy con las reglas de mañana.
        """
        of_the_day = budget_for(domain)

        patch(client, domain, {'daily_inspection_budget': 50, 'manual_reserve': 10})

        of_the_day.refresh_from_db()
        assert of_the_day.limit_total == 1000
        assert of_the_day.manual_reserve == 100


class TestWhatItRejects:
    def test_the_reserve_cannot_exceed_the_budget(self, client, domain):
        response = patch(client, domain, {'daily_inspection_budget': 200, 'manual_reserve': 300})

        assert response.status_code == 422
        error = response.json()['error']
        assert error['code'] == 'VALIDATION_FAILED'
        # Las dos cifras en el mensaje: sin ellas hay que ir a mirar los dos
        # campos para saber cuál de los dos está mal.
        assert '300' in error['message']
        assert '200' in error['message']
        domain.refresh_from_db()
        assert domain.daily_inspection_budget == 1000

    def test_lowering_the_budget_below_the_stored_reserve_points_at_the_budget(
        self, client, domain
    ):
        """
        El campo que se nombra es el que se tocó, no siempre la reserva.

        Si lo que bajó fue el presupuesto, mandar a corregir la reserva manda a
        cambiar un valor que puede estar perfectamente elegido.
        """
        response = patch(client, domain, {'daily_inspection_budget': 50})

        assert response.status_code == 422
        assert response.json()['error']['details']['field'] == 'daily_inspection_budget'

    def test_raising_the_reserve_above_the_stored_budget_points_at_the_reserve(
        self, client, domain
    ):
        response = patch(client, domain, {'manual_reserve': 5000})

        assert response.status_code == 422
        assert response.json()['error']['details']['field'] == 'manual_reserve'

    def test_a_budget_of_zero_is_not_accepted(self, client, domain):
        response = patch(client, domain, {'daily_inspection_budget': 0})

        assert response.status_code == 422
        assert response.json()['error']['details']['field'] == 'daily_inspection_budget'

    def test_a_negative_reserve_is_not_accepted(self, client, domain):
        response = patch(client, domain, {'manual_reserve': -1})

        assert response.status_code == 422
        assert response.json()['error']['details']['field'] == 'manual_reserve'

    def test_a_budget_that_is_not_a_number_is_not_accepted(self, client, domain):
        response = patch(client, domain, {'daily_inspection_budget': 'muchas'})

        assert response.status_code == 422
        domain.refresh_from_db()
        assert domain.daily_inspection_budget == 1000

    def test_a_boolean_does_not_sneak_in_as_a_budget(self, client, domain):
        """
        `True` es un entero en Python: sin cortarlo, guardaría un cupo de 1.

        Un cupo de una consulta por día no se distingue a simple vista de una
        configuración deliberada, y el ciclo tardaría años en dar una vuelta sin
        que nada lo explique.
        """
        response = patch(client, domain, {'daily_inspection_budget': True})

        assert response.status_code == 422
        domain.refresh_from_db()
        assert domain.daily_inspection_budget == 1000

    def test_an_empty_body_is_not_attended_to(self, client, domain):
        response = patch(client, domain, {})

        assert response.status_code == 422
        assert response.json()['error']['code'] == 'VALIDATION_FAILED'

    def test_the_access_state_is_not_edited_through_here(self, client, domain):
        """
        Describe lo que Google confirmó: escribirlo desde acá sería inventarlo.

        La clave se ignora en vez de rechazarse porque quien integra suele
        devolver el dominio entero tal como lo leyó, pero el valor no se toca.
        """
        domain.access_state = AccessState.AWAITING_ACCESS
        domain.save(update_fields=['access_state'])

        response = patch(
            client,
            domain,
            {'access_state': AccessState.OPERATIONAL, 'notifications_enabled': False},
        )

        assert response.status_code == 200
        domain.refresh_from_db()
        assert domain.access_state == AccessState.AWAITING_ACCESS
        assert domain.notifications_enabled is False

    def test_the_shape_of_the_property_is_not_edited_either(self, client, domain):
        response = patch(
            client,
            domain,
            {
                'hostname': 'otro.test',
                'property_uri': 'sc-domain:otro.test',
                'manual_reserve': 50,
            },
        )

        assert response.status_code == 200
        domain.refresh_from_db()
        assert domain.hostname == 'ejemplo.test'
        assert domain.property_uri == 'sc-domain:ejemplo.test'
        assert domain.manual_reserve == 50

    def test_a_domain_created_by_another_profile_can_be_edited(self, client):
        foreign = create_account(email='ajena@ejemplo.test')
        other = create_domain(foreign, hostname='ajeno.test')

        response = client.patch(f'/api/v1/domains/{other.id}', {'manual_reserve': 1}, format='json')

        assert response.status_code == 200
        other.refresh_from_db()
        assert other.manual_reserve == 1

    def test_without_an_api_key_nothing_is_edited(self, domain):
        response = APIClient().patch(
            f'/api/v1/domains/{domain.id}', {'manual_reserve': 1}, format='json'
        )

        assert response.status_code == 401
        domain.refresh_from_db()
        assert domain.manual_reserve == 100


class TestADeactivatedSite:
    """
    Un sitio dado de baja sale del listado, pero sigue contestando por su id.

    No es una inconsistencia: son dos preguntas distintas. «¿Sobre qué puedo
    trabajar?» no incluye lo que la propia API va a rechazar; «¿qué pasó con este
    identificador que tengo guardado?» tiene una respuesta, y es `deactivated_at`.
    Contestar 404 ahí sería mentir sobre algo que existe.
    """

    def test_the_list_leaves_it_out(self, client, account, domain):
        deactivate_domain(domain)
        kept = create_domain(account, hostname='sigue.test')

        response = client.get('/api/v1/domains')

        assert response.status_code == 200
        assert [row['id'] for row in response.data] == [str(kept.id)]

    def test_the_detail_still_answers_and_says_when(self, client, domain):
        deactivate_domain(domain)

        response = client.get(f'/api/v1/domains/{domain.id}')

        assert response.status_code == 200
        assert response.data['deactivated_at'] is not None

    def test_a_connected_site_reports_no_date(self, client, domain):
        response = client.get(f'/api/v1/domains/{domain.id}')

        assert response.status_code == 200
        assert response.data['deactivated_at'] is None
