<?php

namespace WPD\Security;

use WPD\Application\Config;

final class AttemptLimiter
{
    private $config;
    private $clock;
    private $path;
    private $keyPath;

    public function __construct(Config $config, Clock $clock, $varDirectory)
    {
        $this->config = $config;
        $this->clock = $clock;
        $this->path = rtrim((string) $varDirectory, '/\\') . '/attempts.php';
        $this->keyPath = rtrim((string) $varDirectory, '/\\') . '/limiter-key.php';
    }

    public function isBlocked($address)
    {
        $fingerprint = $this->addressFingerprint($address);
        if ($fingerprint === null) {
            return true;
        }
        $transaction = $this->transaction(function (array $entries) use ($fingerprint) {
            $perAddress = 0;
            foreach ($entries as $entry) {
                if (isset($entry['address']) && hash_equals((string) $entry['address'], $fingerprint)) {
                    $perAddress++;
                }
            }
            return array(count($entries) >= 30 || $perAddress >= 5, $entries);
        });
        return !$transaction['ok'] || $transaction['result'];
    }

    public function recordFailure($address)
    {
        $fingerprint = $this->addressFingerprint($address);
        if ($fingerprint === null) {
            return false;
        }
        $now = $this->clock->now();
        $transaction = $this->transaction(function (array $entries) use ($fingerprint, $now) {
            $entries[] = array('address' => $fingerprint, 'at' => $now);
            if (count($entries) > 100) {
                $entries = array_slice($entries, -100);
            }
            return array(true, $entries);
        });
        return $transaction['ok'];
    }

    public function clearAddress($address)
    {
        $fingerprint = $this->addressFingerprint($address);
        if ($fingerprint === null) {
            return false;
        }
        $transaction = $this->transaction(function (array $entries) use ($fingerprint) {
            $entries = array_values(array_filter($entries, function ($entry) use ($fingerprint) {
                return !isset($entry['address']) || !hash_equals((string) $entry['address'], $fingerprint);
            }));
            return array(true, $entries);
        });
        return $transaction['ok'];
    }

    private function transaction(callable $callback)
    {
        $directory = dirname($this->path);
        if (!is_dir($directory) || !is_writable($directory)) {
            return array('ok' => false, 'result' => true);
        }
        $handle = @fopen($this->path, 'c+b');
        if ($handle === false || !@flock($handle, LOCK_EX)) {
            if (is_resource($handle)) {
                fclose($handle);
            }
            return array('ok' => false, 'result' => true);
        }
        $contents = stream_get_contents($handle);
        $entries = array();
        if (is_string($contents) && trim($contents) !== '') {
            $data = $this->decode($contents);
            if ($data === null) {
                flock($handle, LOCK_UN);
                fclose($handle);
                return array('ok' => false, 'result' => true);
            }
            $entries = $data['entries'];
        }
        $cutoff = $this->clock->now() - 600;
        $entries = array_values(array_filter($entries, function ($entry) use ($cutoff) {
            return isset($entry['address'], $entry['at'])
                && is_string($entry['address'])
                && preg_match('/^[a-f0-9]{64}$/', $entry['address'])
                && (int) $entry['at'] >= $cutoff;
        }));
        list($result, $entries) = $callback($entries);
        $payload = array('schema' => 1, 'entries' => array_slice(array_values($entries), -100));
        $signature = $this->sign($payload);
        if ($signature === null) {
            flock($handle, LOCK_UN);
            fclose($handle);
            return array('ok' => false, 'result' => true);
        }
        $payload['signature'] = $signature;
        $encoded = "<?php exit; ?>\n" . json_encode($payload, JSON_UNESCAPED_SLASHES);
        rewind($handle);
        $ok = @ftruncate($handle, 0) && @fwrite($handle, $encoded) === strlen($encoded) && @fflush($handle);
        @chmod($this->path, 0600);
        flock($handle, LOCK_UN);
        fclose($handle);
        return array('ok' => (bool) $ok, 'result' => $result);
    }

    private function decode($contents)
    {
        $json = preg_replace('/^<\?php\s+exit;\s*\?>\s*/', '', (string) $contents);
        $data = json_decode((string) $json, true);
        if (!is_array($data) || !isset($data['schema'], $data['entries'], $data['signature']) || (int) $data['schema'] !== 1 || !is_array($data['entries'])) {
            return null;
        }
        $signature = (string) $data['signature'];
        unset($data['signature']);
        $expected = $this->sign($data);
        return $expected !== null && hash_equals($expected, $signature) ? $data : null;
    }

    private function sign(array $payload)
    {
        unset($payload['signature']);
        $key = $this->key();
        if ($key === null) {
            return null;
        }
        return hash_hmac('sha256', json_encode($payload, JSON_UNESCAPED_SLASHES), $key);
    }

    private function addressFingerprint($address)
    {
        $key = $this->key();
        return $key === null ? null : hash_hmac('sha256', (string) $address, $key);
    }

    private function key()
    {
        if (is_file($this->keyPath)) {
            $contents = @file_get_contents($this->keyPath);
            $json = preg_replace('/^<\?php\s+exit;\s*\?>\s*/', '', (string) $contents);
            $data = json_decode((string) $json, true);
            if (is_array($data) && isset($data['key']) && preg_match('/^[a-f0-9]{64}$/', (string) $data['key'])) {
                return hex2bin($data['key']);
            }
            return null;
        }
        try {
            $hex = bin2hex(random_bytes(32));
        } catch (\Throwable $error) {
            return null;
        }
        $directory = dirname($this->keyPath);
        if (!is_dir($directory) || !is_writable($directory)) {
            return null;
        }
        $handle = @fopen($this->keyPath, 'x+b');
        if ($handle === false) {
            return is_file($this->keyPath) ? $this->key() : null;
        }
        $contents = "<?php exit; ?>\n" . json_encode(array('schema' => 1, 'key' => $hex));
        $written = @fwrite($handle, $contents) === strlen($contents) && @fflush($handle);
        fclose($handle);
        @chmod($this->keyPath, 0600);
        return $written ? hex2bin($hex) : null;
    }
}
