<?php
/**
 * Class Base32 | Classes/Application/Encoder/Base32.php.
 *
 * @author        Robin 'codeFareith' von den Bergen <robinvonberg@gmx.de>
 * @copyright (c) 2018-2019 by Robin von den Bergen
 * @license       http://opensource.org/licenses/gpl-license.php GNU Public License
 * @version       2.0.0
 *
 * @link          https://github.com/codeFareith/cf_google_authenticator
 * @see           https://www.fareith.de
 * @see           https://typo3.org
 */

namespace CodeFareith\CfGoogleAuthenticator\Application\Encoder;

use CodeFareith\CfGoogleAuthenticator\Application\Exception\StringLengthViolation;
use function array_unique;
use function bindec;
use function chr;
use function chunk_split;
use function count;
use function explode;
use function implode;
use function is_int;
use function ord;
use function preg_replace;
use function sprintf;
use function str_pad;
use function str_split;
use function strlen;
use function strpos;
use function strtoupper;
use function substr;
use function trim;
use function vsprintf;

/**
 * Base32 encoder / decoder.
 *
 * Class for encoding / decoding in Base32.
 * Provides four standard character sets:
 * * {@see Base32::CHARSET_RFC4648}
 * * {@see Base32::CHARSET_ZBASE32}
 * * {@see Base32::CHARSET_CROCKFORD}
 * * {@see Base32::CHARSET_BASE32HEX}
 * Default pad string is '='.
 * However, you can define your own character set and pad string during instantiation.
 * In this case, the desired character set's length must be exactly 32 characters and
 * the desired pad string's length must be exactly one character.
 *
 * @package CodeFareith\CfGoogleAuthenticator\Application\Encoder
 */
class Base32
    implements Encoder
{
    public const
        CHARSET_RFC4648 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
        CHARSET_ZBASE32 = 'ybndrfg8ejkmcpqxot1uwisza345h769',
        CHARSET_CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ',
        CHARSET_BASE32HEX = '0123456789ABCDEFGHIJKLMNOPQRSTUV';

    public const
        LENGTH_CHARSET = 32,
        LENGTH_PAD_STRING = 1;

    protected const
        PAD_STRING = '=';

    /**
     * @var string
     */
    protected $charset;

    /**
     * @var string
     */
    protected $padString;

    public function __construct(string $charset = null, string $padString = null)
    {
        $charset = $charset ?? static::CHARSET_RFC4648;
        $padString = $padString ?? static::PAD_STRING;

        $this->setCharset($charset);
        $this->setPadString($padString);
    }

    public function setCharset(string $charset): void
    {
        $charset = $this->sanitizeCharset($charset);
        $this->validateCharset($charset);

        $this->charset = $charset;
    }

    private function sanitizeCharset(string $charset): string
    {
        $split = str_split($charset);
        $unique = array_unique($split);

        return implode('', $unique);
    }

    private function validateCharset(string $charset): void
    {
        $charsetLength = strlen($charset);

        if ($charsetLength !== static::LENGTH_CHARSET) {
            throw StringLengthViolation::requirementNotMet(static::LENGTH_CHARSET, $charsetLength);
        }
    }

    public function charset(): string
    {
        return $this->charset;
    }

    public function setPadString(string $padString): void
    {
        $this->validatePadString($padString);

        $this->padString = $padString;
    }

    private function validatePadString(string $padString): void
    {
        $padStringLength = strlen($padString);

        if ($padStringLength !== static::LENGTH_PAD_STRING) {
            throw StringLengthViolation::requirementNotMet(static::LENGTH_PAD_STRING, $padStringLength);
        }
    }

    public function padString(): string
    {
        return $this->padString;
    }

    public function encode(string $data): string
    {
        $result = '';

        if (trim($data) !== '') {
            $binary = $this->convertStringToBinary($data);
            $array = $this->convertBinaryToArray($binary);
            $result = $this->convertBinariesToBase32($array);
        }

        return $result;
    }

    private function convertStringToBinary(string $data): string
    {
        $chars = str_split($data);

        return $this->convertCharsToBinary($chars);
    }

    private function convertCharsToBinary(array $chars): string
    {
        $result = '';

        foreach ($chars as $char) {
            $result .= $this->convertCharToBinary($char);
        }

        return $result;
    }

    private function convertCharToBinary($char): string
    {
        $ord = ord($char);

        return sprintf('%08b', $ord);
    }

    private function convertBinaryToArray(string $binary): array
    {
        $chunks = $this->convertBinaryToChunks($binary, 5);

        return $this->arrayPadModulo($chunks, 8);
    }

    private function convertBinaryToChunks(string $binary, int $bits): array
    {
        $chunked = chunk_split($binary, $bits, ' ');
        $length = strlen($chunked);
        $trailing = substr($chunked, $length - 1);

        if ($trailing === ' ') {
            $chunked = substr($chunked, 0, $length - 1);
        }

        return explode(' ', $chunked);
    }

    private function arrayPadModulo(array $array, int $multiple, string $pad = null): array
    {
        while (count($array) % $multiple !== 0) {
            $array[] = $pad;
        }

        return $array;
    }

    private function convertBinariesToBase32(array $binaries): string
    {
        $result = '';

        foreach ($binaries as $binary) {
            $result .= $this->getCharFromCharsetByBinary($binary);
        }

        return $result;
    }

    private function getCharFromCharsetByBinary($binary)
    {
        if ($binary === null) {
            $result = $this->padString();
        } else {
            $index = $this->convertBinaryToDecimal($binary, 5);
            $result = $this->charset()[$index];
        }

        return $result;
    }

    private function convertBinaryToDecimal(string $binary, int $length): int
    {
        $bin = str_pad(
            $binary,
            $length,
            0
        );

        return (int) bindec($bin);
    }

    public function decode(string $data): string
    {
        $sanitized = $this->sanitizeCode($data);

        $result = '';

        if ($sanitized !== '') {
            $binaries = $this->convertBase32ToBinaries($sanitized);
            $result = $this->convertBinariesToString($binaries);
        }

        return $result;
    }

    private function sanitizeCode(string $data): string
    {
        $pattern = vsprintf(
            '/[^%s]/',
            [
                $this->charset(),
            ]
        );

        $upperBase32 = strtoupper($data);

        return preg_replace($pattern, '', $upperBase32);
    }

    private function convertBase32ToBinaries(string $base32): array
    {
        $chars = str_split($base32);
        $binary = $this->getBinaryFromCharsetByChars($chars);
        $reduced = $this->stringReduceModulo($binary, 8);

        return $this->convertBinaryToChunks($reduced, 8);
    }

    private function getBinaryFromCharsetByChars(array $chars): string
    {
        $binary = '';

        foreach ($chars as $char) {
            $binary .= $this->getBinaryFromCharsetByChar($char);
        }

        return $binary;
    }

    private function getBinaryFromCharsetByChar(string $char): string
    {
        $binary = '';
        $index = strpos($this->charset(), $char);

        if (is_int($index)) {
            $binary = sprintf('%05b', $index);
        }

        return $binary;
    }

    private function stringReduceModulo(string $string, int $multiplier): string
    {
        while (strlen($string) % $multiplier !== 0) {
            $string = substr($string, 0, -1);
        }

        return $string;
    }

    private function convertBinariesToString(array $binaries): string
    {
        $string = '';

        foreach ($binaries as $binary) {
            $string .= $this->convertBinaryToChar($binary);
        }

        return $string;
    }

    private function convertBinaryToChar($binary): string
    {
        $bindec = $this->convertBinaryToDecimal($binary, 8);

        return chr($bindec);
    }
}