"""
Validación estructural del archivo de clave, antes de tocar la red.

Todo lo que se puede saber sin llamar a Google se decide acá. El motivo es de
producto, no de eficiencia: alguien que nunca entró a Google Cloud va a subir el
archivo equivocado, y los archivos equivocados frecuentes son reconocibles a
simple vista. Mandarlos a Google para que devuelva «invalid credentials» cambia
un mensaje que dice exactamente qué bajó de más por uno que no dice nada.

Cada rechazo nombra el campo que falta o el tipo que se encontró. «El archivo no
es válido» obliga a adivinar; «esto es una credencial de aplicación de
escritorio, la que necesitás es de cuenta de servicio» dice qué volver a bajar.

**Cada rechazo lleva un código, y el código es lo que ve la interfaz.** El texto
sale del catálogo del cliente, que es el único que sabe en qué idioma está
leyendo esta persona. El `message` en inglés sigue existiendo para la API y para
el registro del servidor: quien integra lee su log, no nuestra pantalla.
"""

import hashlib
import json
import re
from dataclasses import dataclass

#: Campos sin los cuales la clave no sirve para firmar nada.
#:
#: `token_uri` está en la lista aunque nadie lo mire en pantalla: es lo que la
#: biblioteca de Google exige para construir el firmante. Sin él, un archivo
#: incompleto pasa esta validación entera y explota recién en la primera llamada
#: a Google, lejos de la pantalla donde se subió y sin decir qué faltaba.
REQUIRED_FIELDS = (
    'type',
    'project_id',
    'private_key_id',
    'private_key',
    'client_email',
    'token_uri',
)

EXPECTED_TYPE = 'service_account'

#: Formato del identificador de proyecto según Google: minúsculas, dígitos y
#: guiones, empezando por letra, de 6 a 30 caracteres.
PROJECT_ID_PATTERN = re.compile(r'^[a-z][a-z0-9-]{4,28}[a-z0-9]$')

#: Una clave de API de Google empieza así. Es el archivo equivocado más
#: frecuente, y además es un secreto: si llegó hasta acá, conviene decir que hay
#: que rotarla.
API_KEY_PATTERN = re.compile(r'^AIza[0-9A-Za-z_-]{35}$')


class KeyFileError:
    """
    Motivos por los que un archivo se rechaza, cada uno con su texto propio.

    Son catorce y no uno genérico porque cada uno se resuelve bajando un archivo
    distinto de una pantalla distinta de Google. Mezclarlos deja a quien sube el
    archivo sin saber adónde volver, que es exactamente lo que este módulo existe
    para evitar.
    """

    PROJECT_ID_MISSING = 'PROJECT_ID_MISSING'
    PROJECT_ID_UPPERCASE = 'PROJECT_ID_UPPERCASE'
    PROJECT_ID_MALFORMED = 'PROJECT_ID_MALFORMED'
    #: La clave es de un proyecto y el formulario declaró otro. Lo levanta
    #: `services.upload_credential`, que es quien puede comparar los dos.
    PROJECT_ID_MISMATCH = 'PROJECT_ID_MISMATCH'
    FILE_NOT_TEXT = 'FILE_NOT_TEXT'
    FILE_IS_API_KEY = 'FILE_IS_API_KEY'
    FILE_NOT_JSON = 'FILE_NOT_JSON'
    FILE_NOT_OBJECT = 'FILE_NOT_OBJECT'
    #: Son dos códigos y no uno con el tipo adentro: la oración nombra la clase
    #: de aplicación —«de escritorio», «web»— y esa palabra se declina distinto
    #: en cada idioma. Un parámetro obligaría a traducirlo aparte y a pegarlo
    #: donde el inglés lo pone.
    FILE_IS_OAUTH_CLIENT_DESKTOP = 'FILE_IS_OAUTH_CLIENT_DESKTOP'
    FILE_IS_OAUTH_CLIENT_WEB = 'FILE_IS_OAUTH_CLIENT_WEB'
    FILE_IS_USER_CREDENTIAL = 'FILE_IS_USER_CREDENTIAL'
    FILE_LOOKS_LIKE_API_KEY = 'FILE_LOOKS_LIKE_API_KEY'
    FIELD_MISSING = 'FIELD_MISSING'
    FIELDS_MISSING = 'FIELDS_MISSING'
    TYPE_MISMATCH = 'TYPE_MISMATCH'
    PRIVATE_KEY_INVALID = 'PRIVATE_KEY_INVALID'


class InvalidKeyFile(Exception):
    """
    El archivo no es una clave de cuenta de servicio utilizable.

    Lleva **el código** —que es lo que la pantalla traduce— y sus `params`, que
    son los datos que la oración necesita: qué campo faltaba, qué tipo se
    encontró, qué decía el archivo donde tenía que decir otra cosa.

    El `message` viaja en inglés y es para la API y para el registro. La pantalla
    no lo usa: si lo usara, la mitad de un formulario en español saldría en
    inglés.
    """

    def __init__(self, code: str, message: str, *, field: str = '', params: dict | None = None):
        super().__init__(message)
        self.code = code
        self.message = message
        self.field = field
        self.params = params or {}


@dataclass(frozen=True)
class ValidatedKey:
    """
    Lo que se puede publicar de una clave válida.

    No incluye `private_key`: el material se cifra y sigue de largo. Todo lo que
    esta estructura expone puede mostrarse en pantalla sin riesgo, y esa es
    justamente la razón de que exista en vez de pasear el diccionario completo.
    """

    raw: str
    project_id: str
    client_email: str
    private_key_id: str
    fingerprint: str


def validate_key_file(content: bytes | str) -> ValidatedKey:
    """Valida el archivo y devuelve sus datos publicables, o explica qué está mal."""
    text = _decode(content)
    data = _parse(text)
    _reject_known_wrong_files(data, text)
    _require_fields(data)
    _check_type(data)
    _check_private_key(data)

    return ValidatedKey(
        raw=text,
        project_id=data['project_id'],
        client_email=data['client_email'],
        private_key_id=data['private_key_id'],
        fingerprint=fingerprint(data['private_key']),
    )


def fingerprint(private_key: str) -> str:
    """
    Huella corta de la clave, para poder identificarla en pantalla.

    Es un hash del material y no una porción de él: mostrar los primeros
    caracteres de una clave privada sería mostrar parte de una clave privada.
    """
    return hashlib.sha256(private_key.encode()).hexdigest()[:32]


def validate_project_id(project_id: str) -> str:
    """
    Valida la forma del identificador de proyecto, sin salir a la red.

    Comprobar contra Google que el proyecto existe requeriría permisos sobre la
    consola que el producto no pide y no necesita. La forma alcanza para atajar
    el error real, que es pegar el *nombre* del proyecto en vez de su
    identificador.
    """
    value = (project_id or '').strip()

    if not value:
        raise InvalidKeyFile(
            KeyFileError.PROJECT_ID_MISSING,
            'The Google Cloud project id is missing.',
            field='project_id',
        )

    if value != value.lower():
        raise InvalidKeyFile(
            KeyFileError.PROJECT_ID_UPPERCASE,
            f'"{value}" has uppercase letters and project ids do not.',
            field='project_id',
            params={'value': value},
        )

    if not PROJECT_ID_PATTERN.match(value):
        raise InvalidKeyFile(
            KeyFileError.PROJECT_ID_MALFORMED,
            f'"{value}" is not shaped like a Google Cloud project id.',
            field='project_id',
            params={'value': value},
        )

    return value


# --- Comprobaciones ---------------------------------------------------------


def _decode(content: bytes | str) -> str:
    if isinstance(content, str):
        return content
    try:
        return content.decode('utf-8')
    except UnicodeDecodeError as exc:
        raise InvalidKeyFile(
            KeyFileError.FILE_NOT_TEXT,
            'The file cannot be read as text.',
        ) from exc


def _parse(text: str) -> dict:
    stripped = text.strip()

    if API_KEY_PATTERN.match(stripped):
        raise InvalidKeyFile(
            KeyFileError.FILE_IS_API_KEY,
            'That is a Google API key, not a service account key.',
        )

    try:
        data = json.loads(stripped)
    except json.JSONDecodeError as exc:
        raise InvalidKeyFile(
            KeyFileError.FILE_NOT_JSON,
            f'The file is not valid JSON: {exc.msg} (line {exc.lineno}).',
            params={'reason': exc.msg, 'line': exc.lineno},
        ) from exc

    if not isinstance(data, dict):
        raise InvalidKeyFile(
            KeyFileError.FILE_NOT_OBJECT,
            f'The file is valid JSON but not an object: found {_type_name(data)}.',
            params={'type': _type_name(data)},
        )

    return data


def _reject_known_wrong_files(data: dict, text: str) -> None:
    """
    Ataja los archivos equivocados que Google entrega en la misma pantalla.

    Los tres casos vienen de la misma sección de la consola y se parecen entre
    sí. Sin este atajo, los tres darían el mismo «falta el campo type», que es
    cierto pero no ayuda a nadie.
    """
    if 'installed' in data or 'web' in data:
        desktop = 'installed' in data
        raise InvalidKeyFile(
            KeyFileError.FILE_IS_OAUTH_CLIENT_DESKTOP
            if desktop
            else KeyFileError.FILE_IS_OAUTH_CLIENT_WEB,
            f'That is an OAuth client credential ({"desktop" if desktop else "web"} app), '
            f'not a service account key.',
        )

    if data.get('type') == 'authorized_user':
        raise InvalidKeyFile(
            KeyFileError.FILE_IS_USER_CREDENTIAL,
            'That is your own Google user credential, not a service account key.',
            field='type',
        )

    if 'private_key' not in data and 'api_key' in text.lower() and 'client_email' not in data:
        raise InvalidKeyFile(
            KeyFileError.FILE_LOOKS_LIKE_API_KEY,
            'The file looks like it holds an API key, not a service account key.',
        )


def _require_fields(data: dict) -> None:
    missing = [name for name in REQUIRED_FIELDS if not data.get(name)]
    if not missing:
        return

    if len(missing) == 1:
        raise InvalidKeyFile(
            KeyFileError.FIELD_MISSING,
            f'The file is missing the "{missing[0]}" field.',
            field=missing[0],
            params={'field': missing[0]},
        )

    listed = ', '.join(f'"{name}"' for name in missing)
    raise InvalidKeyFile(
        KeyFileError.FIELDS_MISSING,
        f'The file is missing several fields: {listed}.',
        field=missing[0],
        params={'fields': listed},
    )


def _check_type(data: dict) -> None:
    if data['type'] != EXPECTED_TYPE:
        raise InvalidKeyFile(
            KeyFileError.TYPE_MISMATCH,
            f'The "type" field says "{data["type"]}" and it has to say "{EXPECTED_TYPE}".',
            field='type',
            params={'type': data['type'], 'expected': EXPECTED_TYPE},
        )


def _check_private_key(data: dict) -> None:
    private_key = data['private_key']
    if not isinstance(private_key, str) or 'PRIVATE KEY' not in private_key:
        raise InvalidKeyFile(
            KeyFileError.PRIVATE_KEY_INVALID,
            'The "private_key" field does not hold a private key.',
            field='private_key',
        )


def _type_name(value) -> str:
    """
    Cómo se llama lo que se encontró, para poder nombrarlo en el rechazo.

    Va en inglés y como código: es un dato del error, y la oración que lo
    contiene se arma en el idioma de quien la lee.
    """
    return {
        list: 'list',
        str: 'string',
        int: 'number',
        float: 'number',
        bool: 'boolean',
        type(None): 'null',
    }.get(type(value), 'something else')
