<?php

/**
 * Copyright (c) Ivan Akimov.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @see https://github.com/vinkla/hashids
 */

namespace Hashids;

use Hashids\Math\Bc;
use Hashids\Math\Gmp;
use Hashids\Math\MathInterface;
use RuntimeException;

class Hashids implements HashidsInterface
{
    /**
     * The seps divider.
     *
     * @var float
     */
    const SEP_DIV = 3.5;

    /**
     * The guard divider.
     *
     * @var float
     */
    const GUARD_DIV = 12;

    /**
     * The alphabet string.
     *
     * @var string
     */
    protected $alphabet;

    /**
     * Shuffled alphabets, referenced by alphabet and salt.
     *
     * @var array
     */
    protected $shuffledAlphabets;

    /**
     * The seps string.
     *
     * @var string
     */
    protected $seps = 'cfhistuCFHISTU';

    /**
     * The guards string.
     *
     * @var string
     */
    protected $guards;

    /**
     * The minimum hash length.
     *
     * @var int
     */
    protected $minHashLength;

    /**
     * The salt string.
     *
     * @var string
     */
    protected $salt;

    /**
     * The math class.
     *
     * @var \Hashids\Math\MathInterface
     */
    protected $math;

    /**
     * Create a new hashids instance.
     *
     * @param string $salt
     * @param int $minHashLength
     * @param string $alphabet
     *
     * @throws \Hashids\HashidsException
     */
    public function __construct($salt = '', $minHashLength = 0, $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890')
    {
        $this->salt = \mb_convert_encoding($salt, 'UTF-8', \mb_detect_encoding($salt));
        $this->minHashLength = $minHashLength;
        $alphabet = \mb_convert_encoding($alphabet, 'UTF-8', \mb_detect_encoding($alphabet));
        $this->alphabet = \implode('', \array_unique($this->multiByteSplit($alphabet)));
        $this->math = $this->getMathExtension();

        if (\mb_strlen($this->alphabet) < 16) {
            throw new HashidsException('Alphabet must contain at least 16 unique characters.');
        }

        if (false !== \mb_strpos($this->alphabet, ' ')) {
            throw new HashidsException('Alphabet can\'t contain spaces.');
        }

        $alphabetArray = $this->multiByteSplit($this->alphabet);
        $sepsArray = $this->multiByteSplit($this->seps);
        $this->seps = \implode('', \array_intersect($sepsArray, $alphabetArray));
        $this->alphabet = \implode('', \array_diff($alphabetArray, $sepsArray));
        $this->seps = $this->shuffle($this->seps, $this->salt);

        if (!$this->seps || (\mb_strlen($this->alphabet) / \mb_strlen($this->seps)) > self::SEP_DIV) {
            $sepsLength = (int) \ceil(\mb_strlen($this->alphabet) / self::SEP_DIV);

            if ($sepsLength > \mb_strlen($this->seps)) {
                $diff = $sepsLength - \mb_strlen($this->seps);
                $this->seps .= \mb_substr($this->alphabet, 0, $diff);
                $this->alphabet = \mb_substr($this->alphabet, $diff);
            }
        }

        $this->alphabet = $this->shuffle($this->alphabet, $this->salt);
        $guardCount = (int) \ceil(\mb_strlen($this->alphabet) / self::GUARD_DIV);

        if (\mb_strlen($this->alphabet) < 3) {
            $this->guards = \mb_substr($this->seps, 0, $guardCount);
            $this->seps = \mb_substr($this->seps, $guardCount);
        } else {
            $this->guards = \mb_substr($this->alphabet, 0, $guardCount);
            $this->alphabet = \mb_substr($this->alphabet, $guardCount);
        }
    }

    /**
     * Encode parameters to generate a hash.
     *
     * @param mixed $numbers
     *
     * @return string
     */
    public function encode(...$numbers): string
    {
        $ret = '';

        if (1 === \count($numbers) && \is_array($numbers[0])) {
            $numbers = $numbers[0];
        }

        if (!$numbers) {
            return $ret;
        }

        foreach ($numbers as $number) {
            $isNumber = \ctype_digit((string) $number);

            if (!$isNumber) {
                return $ret;
            }
        }

        $alphabet = $this->alphabet;
        $numbersSize = \count($numbers);
        $numbersHashInt = 0;

        foreach ($numbers as $i => $number) {
            $numbersHashInt += $this->math->intval($this->math->mod($number, $i + 100));
        }

        $lottery = $ret = \mb_substr($alphabet, $numbersHashInt % \mb_strlen($alphabet), 1);
        foreach ($numbers as $i => $number) {
            $alphabet = $this->shuffle($alphabet, \mb_substr($lottery . $this->salt . $alphabet, 0, \mb_strlen($alphabet)));
            $ret .= $last = $this->hash($number, $alphabet);

            if ($i + 1 < $numbersSize) {
                $number %= (\mb_ord($last, 'UTF-8') + $i);
                $sepsIndex = $this->math->intval($this->math->mod($number, \mb_strlen($this->seps)));
                $ret .= \mb_substr($this->seps, $sepsIndex, 1);
            }
        }

        if (\mb_strlen($ret) < $this->minHashLength) {
            $guardIndex = ($numbersHashInt + \mb_ord(\mb_substr($ret, 0, 1), 'UTF-8')) % \mb_strlen($this->guards);

            $guard = \mb_substr($this->guards, $guardIndex, 1);
            $ret = $guard . $ret;

            if (\mb_strlen($ret) < $this->minHashLength) {
                $guardIndex = ($numbersHashInt + \mb_ord(\mb_substr($ret, 2, 1), 'UTF-8')) % \mb_strlen($this->guards);
                $guard = \mb_substr($this->guards, $guardIndex, 1);

                $ret .= $guard;
            }
        }

        $halfLength = (int) (\mb_strlen($alphabet) / 2);
        while (\mb_strlen($ret) < $this->minHashLength) {
            $alphabet = $this->shuffle($alphabet, $alphabet);
            $ret = \mb_substr($alphabet, $halfLength) . $ret . \mb_substr($alphabet, 0, $halfLength);

            $excess = \mb_strlen($ret) - $this->minHashLength;
            if ($excess > 0) {
                $ret = \mb_substr($ret, (int) ($excess / 2), $this->minHashLength);
            }
        }

        return $ret;
    }

    /**
     * Decode a hash to the original parameter values.
     *
     * @param string $hash
     *
     * @return array
     */
    public function decode($hash): array
    {
        $ret = [];

        if (!\is_string($hash) || !($hash = \trim($hash))) {
            return $ret;
        }

        $alphabet = $this->alphabet;

        $hashBreakdown = \str_replace($this->multiByteSplit($this->guards), ' ', $hash);
        $hashArray = \explode(' ', $hashBreakdown);

        $i = 3 === \count($hashArray) || 2 === \count($hashArray) ? 1 : 0;

        $hashBreakdown = $hashArray[$i];

        if ('' !== $hashBreakdown) {
            $lottery = \mb_substr($hashBreakdown, 0, 1);
            $hashBreakdown = \mb_substr($hashBreakdown, 1);

            $hashBreakdown = \str_replace($this->multiByteSplit($this->seps), ' ', $hashBreakdown);
            $hashArray = \explode(' ', $hashBreakdown);

            foreach ($hashArray as $subHash) {
                $alphabet = $this->shuffle($alphabet, \mb_substr($lottery . $this->salt . $alphabet, 0, \mb_strlen($alphabet)));
                $result = $this->unhash($subHash, $alphabet);
                if ($this->math->greaterThan($result, PHP_INT_MAX)) {
                    $ret[] = $this->math->strval($result);
                } else {
                    $ret[] = $this->math->intval($result);
                }
            }

            if ($this->encode($ret) !== $hash) {
                $ret = [];
            }
        }

        return $ret;
    }

    /**
     * Encode hexadecimal values and generate a hash string.
     *
     * @param string $str
     *
     * @return string
     */
    public function encodeHex($str): string
    {
        if (!\ctype_xdigit((string) $str)) {
            return '';
        }

        $numbers = \trim(chunk_split($str, 12, ' '));
        $numbers = \explode(' ', $numbers);

        foreach ($numbers as $i => $number) {
            $numbers[$i] = \hexdec('1' . $number);
        }

        return $this->encode(...$numbers);
    }

    /**
     * Decode a hexadecimal hash.
     *
     * @param string $hash
     *
     * @return string
     */
    public function decodeHex($hash): string
    {
        $ret = '';
        $numbers = $this->decode($hash);

        foreach ($numbers as $i => $number) {
            $ret .= \mb_substr(dechex($number), 1);
        }

        return $ret;
    }

    /**
     * Shuffle alphabet by given salt.
     *
     * @param string $alphabet
     * @param string $salt
     *
     * @return string
     */
    protected function shuffle($alphabet, $salt): string
    {
        $key = $alphabet . ' ' . $salt;

        if (isset($this->shuffledAlphabets[$key])) {
            return $this->shuffledAlphabets[$key];
        }

        $saltLength = \mb_strlen($salt);
        $saltArray = $this->multiByteSplit($salt);
        if (!$saltLength) {
            return $alphabet;
        }
        $alphabetArray = $this->multiByteSplit($alphabet);
        for ($i = \mb_strlen($alphabet) - 1, $v = 0, $p = 0; $i > 0; $i--, $v++) {
            $v %= $saltLength;
            $p += $int = \mb_ord($saltArray[$v], 'UTF-8');
            $j = ($int + $v + $p) % $i;

            $temp = $alphabetArray[$j];
            $alphabetArray[$j] = $alphabetArray[$i];
            $alphabetArray[$i] = $temp;
        }
        $alphabet = \implode('', $alphabetArray);
        $this->shuffledAlphabets[$key] = $alphabet;

        return $alphabet;
    }

    /**
     * Hash given input value.
     *
     * @param string $input
     * @param string $alphabet
     *
     * @return string
     */
    protected function hash($input, $alphabet): string
    {
        $hash = '';
        $alphabetLength = \mb_strlen($alphabet);

        do {
            $hash = \mb_substr($alphabet, $this->math->intval($this->math->mod($input, $alphabetLength)), 1) . $hash;

            $input = $this->math->divide($input, $alphabetLength);
        } while ($this->math->greaterThan($input, 0));

        return $hash;
    }

    /**
     * Unhash given input value.
     *
     * @param string $input
     * @param string $alphabet
     *
     * @return int
     */
    protected function unhash($input, $alphabet)
    {
        $number = 0;
        $inputLength = \mb_strlen($input);

        if ($inputLength && $alphabet) {
            $alphabetLength = \mb_strlen($alphabet);
            $inputChars = $this->multiByteSplit($input);

            foreach ($inputChars as $char) {
                $position = \mb_strpos($alphabet, $char);
                $number = $this->math->multiply($number, $alphabetLength);
                $number = $this->math->add($number, $position);
            }
        }

        return $number;
    }

    /**
     * Get BC Math or GMP extension.
     *
     * @codeCoverageIgnore
     *
     * @throws \RuntimeException
     *
     * @return \Hashids\Math\MathInterface
     */
    protected function getMathExtension(): MathInterface
    {
        if (\extension_loaded('gmp')) {
            return new Gmp();
        }

        if (\extension_loaded('bcmath')) {
            return new Bc();
        }

        throw new RuntimeException('Missing BC Math or GMP extension.');
    }

    /**
     * Replace simple use of $this->multiByteSplit with multi byte string.
     *
     * @param string $string
     *
     * @return array<int, string>
     */
    protected function multiByteSplit($string): array
    {
        return \preg_split('/(?!^)(?=.)/u', $string) ?: [];
    }
}
