<?php
/**
 * @license MIT
 *
 * Modified using Strauss.
 * @see https://github.com/BrianHenryIE/strauss
 */

declare(strict_types=1);

namespace iThemesSecurity\Strauss\ZxcvbnPhp\Matchers;

use iThemesSecurity\Strauss\ZxcvbnPhp\Matcher;
use iThemesSecurity\Strauss\ZxcvbnPhp\Math\Binomial;

/** @phpstan-consistent-constructor */
class SpatialMatch extends BaseMatch
{
    public const SHIFTED_CHARACTERS = '~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?';

    // Preset properties since adjacency graph is constant for qwerty keyboard and keypad.
    public const KEYBOARD_STARTING_POSITION = 94;
    public const KEYPAD_STARTING_POSITION = 15;
    public const KEYBOARD_AVERAGE_DEGREES = 4.5957446809; // 432 / 94
    public const KEYPAD_AVERAGE_DEGREES = 5.0666666667; // 76 / 15

    public $pattern = 'spatial';

    /** @var int The number of characters the shift key was held for in the token. */
    public $shiftedCount;

    /** @var int The number of turns on the keyboard required to complete the token. */
    public $turns;

    /** @var string The keyboard layout that the token is a spatial match on. */
    public $graph;

    /** @var array A cache of the adjacency_graphs json file */
    protected static $adjacencyGraphs = [];

    /**
     * Match spatial patterns based on keyboard layouts (e.g. qwerty, dvorak, keypad).
     *
     * @param string $password
     * @param array $userInputs
     * @param array $graphs
     * @return SpatialMatch[]
     */
    public static function match(string $password, array $userInputs = [], array $graphs = []): array
    {

        $matches = [];
        if (!$graphs) {
            $graphs = static::getAdjacencyGraphs();
        }
        foreach ($graphs as $name => $graph) {
            $results = static::graphMatch($password, $graph, $name);
            foreach ($results as $result) {
                $result['graph'] = $name;
                $matches[] = new static($password, $result['begin'], $result['end'], $result['token'], $result);
            }
        }
        Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
        return $matches;
    }

    /**
     * @return array{'warning': string, "suggestions": string[]}
     */
    public function getFeedback(bool $isSoleMatch): array
    {
        $warning = $this->turns == 1
            ? 'Straight rows of keys are easy to guess'
            : 'Short keyboard patterns are easy to guess';

        return [
            'warning' => $warning,
            'suggestions' => [
                'Use a longer keyboard pattern with more turns'
            ]
        ];
    }

    /**
     * @param string $password
     * @param int $begin
     * @param int $end
     * @param string $token
     * @param array $params An array with keys: [graph (required), shifted_count, turns].
     */
    public function __construct(string $password, int $begin, int $end, string $token, array $params = [])
    {
        parent::__construct($password, $begin, $end, $token);
        $this->graph = $params['graph'];
        if (!empty($params)) {
            $this->shiftedCount = $params['shifted_count'] ?? null;
            $this->turns = $params['turns'] ?? null;
        }
    }

    /**
     * Match spatial patterns in a adjacency graph.
     * @param string $password
     * @param array $graph
     * @param string $graphName
     * @return array
     */
    protected static function graphMatch(string $password, array $graph, string $graphName): array
    {
        $result = [];
        $i = 0;

        $passwordLength = mb_strlen($password);

        while ($i < $passwordLength - 1) {
            $j = $i + 1;
            $lastDirection = null;
            $turns = 0;
            $shiftedCount = 0;

            // Check if the initial character is shifted
            if ($graphName === 'qwerty' || $graphName === 'dvorak') {
                if (mb_strpos(self::SHIFTED_CHARACTERS, mb_substr($password, $i, 1)) !== false) {
                    $shiftedCount++;
                }
            }

            while (true) {
                $prevChar = mb_substr($password, $j - 1, 1);
                $found = false;
                $curDirection = -1;
                $adjacents = $graph[$prevChar] ?? [];

                // Consider growing pattern by one character if j hasn't gone over the edge.
                if ($j < $passwordLength) {
                    $curChar = mb_substr($password, $j, 1);
                    foreach ($adjacents as $adj) {
                        $curDirection += 1;
                        if ($adj === null) {
                            continue;
                        }
                        $curCharPos = static::indexOf($adj, $curChar);
                        if ($curCharPos !== -1) {
                            $found = true;
                            $foundDirection = $curDirection;

                            if ($curCharPos === 1) {
                                // index 1 in the adjacency means the key is shifted, 0 means unshifted: A vs a, % vs 5, etc.
                                // for example, 'q' is adjacent to the entry '2@'. @ is shifted w/ index 1, 2 is unshifted.
                                $shiftedCount += 1;
                            }
                            if ($lastDirection !== $foundDirection) {
                                // adding a turn is correct even in the initial case when last_direction is null:
                                // every spatial pattern starts with a turn.
                                $turns += 1;
                                $lastDirection = $foundDirection;
                            }

                            break;
                        }
                    }
                }

                // if the current pattern continued, extend j and try to grow again
                if ($found) {
                    $j += 1;
                } else {
                    // otherwise push the pattern discovered so far, if any...

                    // Ignore length 1 or 2 chains.
                    if ($j - $i > 2) {
                        $result[] = [
                            'begin' => $i,
                            'end' => $j - 1,
                            'token' => mb_substr($password, $i, $j - $i),
                            'turns' => $turns,
                            'shifted_count' => $shiftedCount
                        ];
                    }
                    // ...and then start a new search for the rest of the password.
                    $i = $j;
                    break;
                }
            }
        }

        return $result;
    }

    /**
     * Get the index of a string a character first
     *
     * @param string $string
     * @param string $char
     *
     * @return int
     */
    protected static function indexOf(string $string, string $char): int
    {
        $pos = mb_strpos($string, $char);
        return ($pos === false ? -1 : $pos);
    }

    /**
     * Load adjacency graphs.
     *
     * @return array
     */
    public static function getAdjacencyGraphs(): array
    {
        if (empty(self::$adjacencyGraphs)) {
            $json = file_get_contents(dirname(__FILE__) . '/adjacency_graphs.json');
            $data = json_decode($json, true);

            // This seems pointless, but the data file is not guaranteed to be in any particular order.
            // We want to be in the exact order below so as to match most closely with upstream, because when a match
            // can be found in multiple graphs (such as 789), the one that's listed first is that one that will be picked.
            $data = [
                'qwerty' => $data['qwerty'],
                'dvorak' => $data['dvorak'],
                'keypad' => $data['keypad'],
                'mac_keypad' => $data['mac_keypad'],
            ];
            self::$adjacencyGraphs = $data;
        }

        return self::$adjacencyGraphs;
    }

    protected function getRawGuesses(): float
    {
        if ($this->graph === 'qwerty' || $this->graph === 'dvorak') {
            $startingPosition = self::KEYBOARD_STARTING_POSITION;
            $averageDegree = self::KEYBOARD_AVERAGE_DEGREES;
        } else {
            $startingPosition = self::KEYPAD_STARTING_POSITION;
            $averageDegree = self::KEYPAD_AVERAGE_DEGREES;
        }

        $guesses = 0;
        $length = mb_strlen($this->token);
        $turns = $this->turns;

        // estimate the number of possible patterns w/ length L or less with t turns or less.
        for ($i = 2; $i <= $length; $i++) {
            $possibleTurns = min($turns, $i - 1);
            for ($j = 1; $j <= $possibleTurns; $j++) {
                $guesses += Binomial::binom($i - 1, $j - 1) * $startingPosition * pow($averageDegree, $j);
            }
        }

        // add extra guesses for shifted keys. (% instead of 5, A instead of a.)
        // math is similar to extra guesses of l33t substitutions in dictionary matches.
        if ($this->shiftedCount > 0) {
            $shifted = $this->shiftedCount;
            $unshifted = $length - $shifted;

            if ($unshifted === 0) {
                $guesses *= 2;
            } else {
                $variations = 0;
                for ($i = 1; $i <= min($shifted, $unshifted); $i++) {
                    $variations += Binomial::binom($shifted + $unshifted, $i);
                }
                $guesses *= $variations;
            }
        }

        return $guesses;
    }
}
