"""Implementations of various monoalphabetic substitution ciphers.

This module contains monoalphabetic cipher algorithms, each producing a
unique cipher alphabet.
"""

__all__: list[str] = [
    "Atbash",
    "Shift",
    "Caesar",
    "Rot13",
    "MixedAlphabet",
    "SimpleSubstitution",
    "Baconian",
    "PolybiusSquare",
]

import random
import string
from typing import override

from .base import MonoalphabeticSubstitution


class Atbash(MonoalphabeticSubstitution):
    """The cipher alphabet is the reverse of the standard alphabet (z-a)."""

    def __init__(self):
        """Initializes the Atbash cipher with a reversed alphabet."""
        super().__init__(string.ascii_lowercase[::-1])


class Shift(MonoalphabeticSubstitution):
    """Produces a cipher alphabet based on a user-defined shift."""

    def __init__(self, shift: int):
        """Initializes the Shift cipher with a user-defined shift.

        Args:
            shift (int): The integer value to shift the alphabet by.
        """
        base: str = string.ascii_lowercase
        # The modulo ensures shifts larger than 26 wrap around safely
        self.shift: int = shift % len(base)
        # creating reverse alphabet (z-a)
        cipher_alphabet: str = base[self.shift :] + base[: self.shift]
        super().__init__(cipher_alphabet)

    def __repr__(self) -> str:
        """Returns a string representation to recreate the Shift object.

        Returns:
            str: A string representing the object.
        """
        return f"{self.__class__.__name__}(shift={self.shift})"


class Caesar(Shift):
    """A simple monoalphabetic substitution cipher using a fixed shift of 3."""

    def __init__(self):
        """Initializes the Caesar cipher with a fixed shift of 3."""
        super().__init__(shift=3)


class Rot13(Shift):
    """A simple monoalphabetic substitution cipher using a fixed shift of 13."""

    def __init__(self):
        """Initializes the Rot13 cipher with a fixed shift of 13."""
        super().__init__(shift=13)


class MixedAlphabet(MonoalphabeticSubstitution):
    """Generates a unique cipher alphabet using a keyword."""

    def __init__(self, keyword: str) -> None:
        """Initializes the MixedAlphabet cipher with a user-defined keyword.

        The cipher alphabet is created by taking the unique letters of the
        keyword, followed by the remaining letters of the alphabet in
        ascending order.

        Args:
            keyword (str): The keyword used to generate the cipher alphabet.
        """
        # removing duplicate letter in the keyword to make the cipher_alphanet 26 chars
        self.keyword: str = "".join(filter(str.isalpha, keyword.lower()))
        # make keyword unique so the cipher alphanet does not include duplicates,
        # and it should 26 exact
        clean_keyword: list[str] = list(dict.fromkeys(self.keyword))
        cipher_alphabet: list[str] = clean_keyword
        # append remaining characters in cipher_alphabet
        for letter in string.ascii_lowercase:
            if letter not in cipher_alphabet:
                cipher_alphabet.append(letter)

        super().__init__(cipher_alphabet)

    def __repr__(self) -> str:
        """Returns a string representation to recreate the object.

        Returns:
            str: A string representing the object.
        """
        return f"{self.__class__.__name__}(keyword={self.keyword!r})"


class SimpleSubstitution(MonoalphabeticSubstitution):
    """A simple substitution cipher using a pre-agreed mapping.

    The sender and receiver must agree on an exact mapping between the
    plaintext and cipher alphabets. No algorithm is used to generate the
    mapping; it must be memorized or recorded.
    """

    # user need to know the cipher_alphabet as it is a key to cipher and decipher.
    # user provide one or generate one using a static method.
    def __init__(self, cipher_alphabet: str) -> None:
        """Initializes the cipher with a provided cipher alphabet.

        Args:
            cipher_alphabet (str): The 26-character cipher alphabet to use.

        Raises:
            ValueError: If the cipher alphabet is not exactly 26 unique
                characters.
        """
        # removing duplicate character in cipher_alphabet and making sure it is exactly
        # 26 OR check if the length is 26
        if (
            len(dict.fromkeys(c.lower() for c in cipher_alphabet)) != 26
            or len(cipher_alphabet) != 26
        ):
            raise ValueError(
                "cipher_alphabets must be unique and 26 char long OR generate one by "
                "executing 'SimpleSubstitution.generate_cipher_alphabet()'"
            )
        self.cipher_alphabet: str = cipher_alphabet
        super().__init__(self.cipher_alphabet)

    @staticmethod
    def generate_cipher_alphabet() -> str:
        """Generates a random 26-character cipher alphabet.

        Returns:
            str: A random permutation of the lowercase alphabet.
        """
        return "".join(random.sample(string.ascii_lowercase, k=26))

    def __repr__(self) -> str:
        """Returns a string representation to recreate the object.

        Returns:
            str: A string representing the object.
        """
        return f"{self.__class__.__name__}(cipher_alphabet={self.cipher_alphabet!r})"


class Baconian(MonoalphabeticSubstitution):
    """A substitution cipher using a 5-character binary representation."""

    modern_baconian_cipher: list[str] = [
        "aaaaa",  # a
        "aaaab",  # b
        "aaaba",  # c
        "aaabb",  # d
        "aabaa",  # e
        "aabab",  # f
        "aabba",  # g
        "aabbb",  # h
        "abaaa",  # i
        "abaab",  # j
        "ababa",  # k
        "ababb",  # l
        "abbaa",  # m
        "abbab",  # n
        "abbba",  # o
        "abbbb",  # p
        "baaaa",  # q
        "baaab",  # r
        "baaba",  # s
        "baabb",  # t
        "babaa",  # u
        "babab",  # v
        "babba",  # w
        "babbb",  # x
        "bbaaa",  # y
        "bbaab",  # z
    ]

    classic_baconian_cipher: list[str] = [
        "aaaaa",  # A
        "aaaab",  # B
        "aaaba",  # C
        "aaabb",  # D
        "aabaa",  # E
        "aabab",  # F
        "aabba",  # G
        "aabbb",  # H
        "abaaa",  # I / J
        "abaaa",  # I / J
        "abaab",  # K
        "ababa",  # L
        "ababb",  # M
        "abbaa",  # N
        "abbab",  # O
        "abbba",  # P
        "abbbb",  # Q
        "baaaa",  # R
        "baaab",  # S
        "baaba",  # T
        "baabb",  # U / V
        "baabb",  # U / V
        "babaa",  # W
        "babab",  # X
        "babba",  # Y
        "babbb",  # Z
    ]

    def __init__(self, modern_implementation: bool = True) -> None:
        """Initializes the Baconian cipher.

        Args:
            modern_implementation (bool): Whether to use the modern or classic
                implementation. Defaults to True.
        """
        self.modern_implementation: bool = modern_implementation
        self.cipher_alphabet: list[str] = (
            self.modern_baconian_cipher
            if self.modern_implementation
            else self.classic_baconian_cipher
        )
        super().__init__(self.cipher_alphabet)

    @override
    def decipher(self, text: str) -> str:
        """Decrypts the given text.

        Processes the text in 5-character blocks, retrieves the mapped value
        for each block, and appends it to the result.

        Args:
            text (str): The ciphertext string to decrypt.

        Returns:
            str: The resulting plaintext string.
        """
        plain_text = ""
        i = 0
        # setting up the cipher word length
        word_length = 5
        while i < len(text):
            if not text[i].isalpha():
                plain_text += text[i]
                i += 1
            else:
                # grabbing next five chars
                block: str = text[i : i + word_length]
                # look into dictionary to decipher to single char
                plain_text += self.reverse_mapping.get(block, block)
                # switch index to next five chars
                i += word_length
        return plain_text

    def __repr__(self) -> str:
        """Returns a string representation to recreate the object.

        Returns:
            str: A string representing the object.
        """
        return f"{self.__class__.__name__}(modern_implementation={self.modern_implementation})"


class PolybiusSquare(MonoalphabeticSubstitution):
    """A substitution cipher that uses a 5x5 grid to represent each letter.

    A Polybius Square typically uses a 5x5 grid, resulting in 25
    coordinates. To accommodate the 26-letter English alphabet, two letters
    (usually 'I' and 'J') share the same cell. This implementation uses a
    26-item list where 'I' and 'J' have identical coordinates.
    """

    cipher_alphabets = [
        "11",
        "12",
        "13",
        "14",
        "15",
        "21",
        "22",
        "23",
        "24",
        "24",
        "25",
        "31",
        "32",
        "33",
        "34",
        "35",
        "41",
        "42",
        "43",
        "44",
        "45",
        "51",
        "52",
        "53",
        "54",
        "55",
    ]

    def __init__(self) -> None:
        """Initializes the Polybius Square cipher."""
        super().__init__(self.cipher_alphabets)

    @override
    def decipher(self, text: str) -> str:
        """Decrypts the given text.

        Processes the text in 2-character blocks, retrieves the mapped value
        for each block, and appends it to the result.

        Args:
            text (str): The ciphertext string to decrypt.

        Returns:
            str: The resulting plaintext string.
        """
        plain_text = ""
        i = 0
        # setting up the cipher word length
        word_length = 2
        while i < len(text):
            if not text[i].isnumeric():
                plain_text += text[i]
                i += 1
            else:
                # grabbing next two chars
                block = text[i : i + word_length]
                # look into dictionary to decipher to single char
                plain_text += self.reverse_mapping.get(block, block)
                # switch index to next two chars
                i += word_length
        return plain_text
