# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.


import os

import pytest

from cryptography.cobblestone import (
    Cobblestone128Decryptor,
    Cobblestone128Encryptor,
    Cobblestone256Decryptor,
    Cobblestone256Encryptor,
)
from cryptography.exceptions import AlreadyFinalized, InvalidTag

CHUNK_SIZE = 16 * 1024
TAG_LEN = 16
WIRE_CHUNK_SIZE = CHUNK_SIZE + TAG_LEN
SALT_LEN = 24
COMMITMENT_LEN = 32
HEADER_LEN = SALT_LEN + COMMITMENT_LEN

VARIANTS = [
    pytest.param(
        (Cobblestone128Encryptor, Cobblestone128Decryptor, 16),
        id="cobblestone128",
    ),
    pytest.param(
        (Cobblestone256Encryptor, Cobblestone256Decryptor, 32),
        id="cobblestone256",
    ),
]


def _encrypt_all(encryptor_cls, key: bytes, context: bytes, plaintext: bytes):
    enc = encryptor_cls(key, context)
    return enc.update(plaintext) + enc.finalize()


def _decrypt_all(decryptor_cls, key: bytes, context: bytes, ciphertext: bytes):
    dec = decryptor_cls(key, context)
    return dec.update(ciphertext) + dec.finalize()


MESSAGE_LENGTHS = [
    0,
    1,
    57,
    CHUNK_SIZE - 1,
    CHUNK_SIZE,
    CHUNK_SIZE + 1,
    20 * 1024,
    2 * CHUNK_SIZE - 1,
    2 * CHUNK_SIZE,
    2 * CHUNK_SIZE + 1,
    3 * CHUNK_SIZE + 5000,
]


@pytest.mark.parametrize("variant", VARIANTS)
class TestCobblestone:
    @pytest.mark.parametrize("length", MESSAGE_LENGTHS)
    def test_round_trip(self, variant, length):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        context = b"test context"
        plaintext = os.urandom(length)

        ciphertext = _encrypt_all(encryptor_cls, key, context, plaintext)
        n_chunks = length // CHUNK_SIZE + 1
        assert len(ciphertext) == HEADER_LEN + length + n_chunks * TAG_LEN
        assert (
            _decrypt_all(decryptor_cls, key, context, ciphertext) == plaintext
        )

    @pytest.mark.parametrize("piece_size", [1, 57, 1024, 16384, 16400])
    def test_streaming(self, variant, piece_size):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        context = b""
        plaintext = os.urandom(2 * CHUNK_SIZE + 12345)

        enc = encryptor_cls(key, context)
        ciphertext = b""
        for i in range(0, len(plaintext), piece_size):
            ciphertext += enc.update(plaintext[i : i + piece_size])
        ciphertext += enc.finalize()
        # The result matches a single-shot encryption's structure, and
        # decrypts to the plaintext regardless of how the ciphertext is
        # split up.
        assert len(ciphertext) == HEADER_LEN + len(plaintext) + 3 * TAG_LEN

        dec = decryptor_cls(key, context)
        decrypted = b""
        for i in range(0, len(ciphertext), piece_size):
            decrypted += dec.update(ciphertext[i : i + piece_size])
        decrypted += dec.finalize()
        assert decrypted == plaintext

    def test_empty_message(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        enc = encryptor_cls(key, b"ctx")
        ciphertext = enc.finalize()
        assert len(ciphertext) == HEADER_LEN + TAG_LEN
        assert _decrypt_all(decryptor_cls, key, b"ctx", ciphertext) == b""

    def test_update_with_empty_data_emits_header(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        enc = encryptor_cls(key, b"")
        header = enc.update(b"")
        assert len(header) == HEADER_LEN
        assert enc.update(b"") == b""
        ciphertext = header + enc.finalize()
        assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == b""

    def test_exact_chunk_boundary_has_empty_final_chunk(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(CHUNK_SIZE)
        ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)
        # One full chunk plus an empty final chunk.
        assert len(ciphertext) == HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN
        assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == plaintext

    def test_decrypter_streams_plaintext_incrementally(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(3 * CHUNK_SIZE)
        ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)

        dec = decryptor_cls(key, b"")
        out = dec.update(ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE])
        assert out == plaintext[:CHUNK_SIZE]
        out = dec.update(ciphertext[HEADER_LEN + WIRE_CHUNK_SIZE :])
        assert out == plaintext[CHUNK_SIZE:]
        assert dec.finalize() == b""

    def test_wrong_key(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message")
        dec = decryptor_cls(encryptor_cls.generate_key(), b"")
        with pytest.raises(InvalidTag):
            dec.update(ciphertext)

    def test_wrong_context(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        ciphertext = _encrypt_all(encryptor_cls, key, b"context a", b"msg")
        dec = decryptor_cls(key, b"context b")
        with pytest.raises(InvalidTag):
            dec.update(ciphertext)

    @pytest.mark.parametrize(
        "position",
        [
            0,  # salt
            SALT_LEN,  # commitment
            HEADER_LEN,  # first chunk ciphertext
            HEADER_LEN + WIRE_CHUNK_SIZE - 1,  # first chunk tag
            HEADER_LEN + WIRE_CHUNK_SIZE + 3,  # final chunk
        ],
    )
    def test_tampering_detected(self, variant, position):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(CHUNK_SIZE + 100)
        ciphertext = bytearray(
            _encrypt_all(encryptor_cls, key, b"", plaintext)
        )
        ciphertext[position] ^= 1

        dec = decryptor_cls(key, b"")
        with pytest.raises(InvalidTag):
            dec.update(bytes(ciphertext))
            dec.finalize()

    def test_swapped_chunks_detected(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(2 * CHUNK_SIZE + 100)
        ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)

        chunk0_start = HEADER_LEN
        chunk1_start = HEADER_LEN + WIRE_CHUNK_SIZE
        chunk2_start = HEADER_LEN + 2 * WIRE_CHUNK_SIZE
        swapped = (
            ciphertext[:HEADER_LEN]
            + ciphertext[chunk1_start:chunk2_start]
            + ciphertext[chunk0_start:chunk1_start]
            + ciphertext[chunk2_start:]
        )
        dec = decryptor_cls(key, b"")
        with pytest.raises(InvalidTag):
            dec.update(swapped)

    @pytest.mark.parametrize(
        "length",
        [
            0,
            1,
            HEADER_LEN - 1,
            HEADER_LEN,  # no final chunk at all
            HEADER_LEN + TAG_LEN - 1,  # final chunk shorter than its tag
            HEADER_LEN + WIRE_CHUNK_SIZE,  # ends on a chunk boundary
            HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN - 1,
        ],
    )
    def test_truncation_detected(self, variant, length):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(CHUNK_SIZE + 100)
        ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)

        dec = decryptor_cls(key, b"")
        with pytest.raises(InvalidTag):
            dec.update(ciphertext[:length])
            dec.finalize()

    def test_extension_detected(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message")

        dec = decryptor_cls(key, b"")
        with pytest.raises(InvalidTag):
            dec.update(ciphertext + b"extra garbage bytes!")
            dec.finalize()

    def test_ciphertexts_are_randomized(self, variant):
        encryptor_cls, _, _ = variant
        key = encryptor_cls.generate_key()
        assert _encrypt_all(encryptor_cls, key, b"", b"data") != _encrypt_all(
            encryptor_cls, key, b"", b"data"
        )

    def test_update_into(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(CHUNK_SIZE + 100)

        enc = encryptor_cls(key, b"")
        buf = bytearray(HEADER_LEN + 2 * WIRE_CHUNK_SIZE)
        n = enc.update_into(plaintext, buf)
        assert n == HEADER_LEN + WIRE_CHUNK_SIZE
        ciphertext = bytes(buf[:n]) + enc.finalize()
        assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == plaintext

        dec = decryptor_cls(key, b"")
        out = bytearray(2 * CHUNK_SIZE)
        n = dec.update_into(ciphertext, out)
        assert n == CHUNK_SIZE
        assert bytes(out[:n]) + dec.finalize() == plaintext

    def test_update_into_accepts_larger_buffer(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        enc = encryptor_cls(key, b"")
        buf = bytearray(10 * WIRE_CHUNK_SIZE)
        n = enc.update_into(b"abc", buf)
        assert n == HEADER_LEN
        ciphertext = bytes(buf[:n]) + enc.finalize()
        assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == b"abc"

    def test_update_into_buffer_too_small(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        enc = encryptor_cls(key, b"")
        with pytest.raises(ValueError, match="buffer must be at least"):
            enc.update_into(b"abc", bytearray(HEADER_LEN - 1))
        # The context remains usable after the failed call.
        ciphertext = enc.update(b"abc") + enc.finalize()

        dec = decryptor_cls(key, b"")
        with pytest.raises(ValueError, match="buffer must be at least"):
            dec.update_into(
                ciphertext + bytes(WIRE_CHUNK_SIZE), bytearray(CHUNK_SIZE - 1)
            )
        assert dec.update(ciphertext) == b""
        assert dec.finalize() == b"abc"

    def test_update_into_zero_output(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        dec = decryptor_cls(key, b"")
        assert dec.update_into(b"", bytearray(0)) == 0

    def test_use_after_finalize(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        enc = encryptor_cls(key, b"")
        ciphertext = enc.update(b"data") + enc.finalize()
        with pytest.raises(AlreadyFinalized):
            enc.update(b"more")
        with pytest.raises(AlreadyFinalized):
            enc.update_into(b"more", bytearray(WIRE_CHUNK_SIZE))
        with pytest.raises(AlreadyFinalized):
            enc.finalize()

        dec = decryptor_cls(key, b"")
        dec.update(ciphertext)
        dec.finalize()
        with pytest.raises(AlreadyFinalized):
            dec.update(b"more")
        with pytest.raises(AlreadyFinalized):
            dec.update_into(b"more", bytearray(WIRE_CHUNK_SIZE))
        with pytest.raises(AlreadyFinalized):
            dec.finalize()

    def test_decryptor_unusable_after_invalid_tag(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        ciphertext = bytearray(_encrypt_all(encryptor_cls, key, b"", b"data"))
        ciphertext[-1] ^= 1

        dec = decryptor_cls(key, b"")
        dec.update(bytes(ciphertext))
        with pytest.raises(InvalidTag):
            dec.finalize()
        # All subsequent operations fail.
        with pytest.raises(AlreadyFinalized):
            dec.update(b"")
        with pytest.raises(AlreadyFinalized):
            dec.finalize()

    def test_decryptor_update_into_unusable_after_invalid_tag(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        plaintext = os.urandom(CHUNK_SIZE + 100)
        ciphertext = bytearray(
            _encrypt_all(encryptor_cls, key, b"", plaintext)
        )
        ciphertext[HEADER_LEN] ^= 1  # corrupt the first full chunk

        dec = decryptor_cls(key, b"")
        buf = bytearray(2 * CHUNK_SIZE)
        with pytest.raises(InvalidTag):
            dec.update_into(bytes(ciphertext), buf)
        # All subsequent operations fail.
        with pytest.raises(AlreadyFinalized):
            dec.update_into(b"", buf)
        with pytest.raises(AlreadyFinalized):
            dec.finalize()

    def test_finalize_only_decryptor_rejects_empty_stream(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        dec = decryptor_cls(key, b"")
        with pytest.raises(InvalidTag):
            dec.finalize()

    def test_generate_key(self, variant):
        encryptor_cls, _, key_len = variant
        key = encryptor_cls.generate_key()
        assert isinstance(key, bytes)
        assert len(key) == key_len
        assert encryptor_cls.generate_key() != encryptor_cls.generate_key()

    def test_invalid_key_size(self, variant):
        encryptor_cls, decryptor_cls, key_len = variant
        for length in [0, key_len - 1, key_len + 1, 64]:
            with pytest.raises(ValueError):
                encryptor_cls(b"\x00" * length, b"")
            with pytest.raises(ValueError):
                decryptor_cls(b"\x00" * length, b"")

    def test_invalid_types(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = encryptor_cls.generate_key()
        with pytest.raises(TypeError):
            encryptor_cls("not bytes", b"")
        with pytest.raises(TypeError):
            encryptor_cls(key, "not bytes")
        with pytest.raises(TypeError):
            decryptor_cls("not bytes", b"")
        enc = encryptor_cls(key, b"")
        with pytest.raises(TypeError):
            enc.update("not bytes")
        with pytest.raises(TypeError):
            enc.update_into(b"", b"immutable")

    def test_accepts_buffers(self, variant):
        encryptor_cls, decryptor_cls, _ = variant
        key = bytearray(encryptor_cls.generate_key())
        plaintext = os.urandom(1000)
        enc = encryptor_cls(key, memoryview(b"ctx"))
        ciphertext = enc.update(memoryview(plaintext)) + enc.finalize()
        dec = decryptor_cls(memoryview(bytes(key)), bytearray(b"ctx"))
        assert dec.update(bytearray(ciphertext)) + dec.finalize() == plaintext


class TestVariantsAreDistinct:
    def test_key_sizes_differ(self):
        assert len(Cobblestone128Encryptor.generate_key()) == 16
        assert len(Cobblestone256Encryptor.generate_key()) == 32

    def test_cross_variant_key_sizes_rejected(self):
        key128 = Cobblestone128Encryptor.generate_key()
        key256 = Cobblestone256Encryptor.generate_key()
        with pytest.raises(ValueError, match="key must be 16 bytes"):
            Cobblestone128Encryptor(key256, b"")
        with pytest.raises(ValueError, match="key must be 32 bytes"):
            Cobblestone256Decryptor(key128, b"")
