<?php

// +----------------------------------------------------------------------
// | ThinkLibrary 6.0 for ThinkPhP 6.0
// +----------------------------------------------------------------------
// | 版权所有 2017~2020 [ https://www.dtapp.net ]
// +----------------------------------------------------------------------
// | 官方网站: https://gitee.com/liguangchun/ThinkLibrary
// +----------------------------------------------------------------------
// | 开源协议 ( https://mit-license.org )
// +----------------------------------------------------------------------
// | gitee 仓库地址 ：https://gitee.com/liguangchun/ThinkLibrary
// | github 仓库地址 ：https://github.com/GC0202/ThinkLibrary
// | Packagist 地址 ：https://packagist.org/packages/liguangchun/think-library
// +----------------------------------------------------------------------

namespace DtApp\ThinkLibrary\service\Ip;

use BadMethodCallException;
use Exception;
use InvalidArgumentException;

class IpIpReader
{
    public const IPV4 = 1;
    public const IPV6 = 2;

    private $file;
    private $fileSize = 0;
    private $nodeCount = 0;
    private $nodeOffset = 0;

    private $meta = [];

    private $database;

    /**
     * Reader constructor.
     * @param $database
     * @throws Exception
     */
    public function __construct($database)
    {
        $this->database = $database;

        $this->init();
    }

    /**
     * @throws Exception
     */
    private function init(): void
    {
        if (is_readable($this->database) === FALSE) {
            throw new InvalidArgumentException("The IP Database file \"{$this->database}\" does not exist or is not readable.");
        }
        $this->file = @fopen($this->database, 'rb');
        if ($this->file === FALSE) {
            throw new InvalidArgumentException("IP Database File opening \"{$this->database}\".");
        }
        $this->fileSize = @filesize($this->database);
        if ($this->fileSize === FALSE) {
            throw new \UnexpectedValueException("Error determining the size of \"{$this->database}\".");
        }

        $metaLength = unpack('N', fread($this->file, 4))[1];
        $text = fread($this->file, $metaLength);

        $this->meta = json_decode($text, 1);

        if (isset($this->meta['fields']) === FALSE || isset($this->meta['languages']) === FALSE) {
            throw new Exception('IP Database metadata error.');
        }

        $fileSize = 4 + $metaLength + $this->meta['total_size'];
        if ($fileSize !== $this->fileSize) {
            throw  new Exception('IP Database size error.');
        }

        $this->nodeCount = $this->meta['node_count'];
        $this->nodeOffset = 4 + $metaLength;
    }

    /**
     * @param $ip
     * @param string $language
     * @return array|NULL
     */
    public function find($ip, $language): ?array
    {
        if (is_resource($this->file) === FALSE) {
            throw new BadMethodCallException('IPIP DB closed.');
        }

        if (isset($this->meta['languages'][$language]) === FALSE) {
            throw new InvalidArgumentException("language : {$language} not support.");
        }

        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) === FALSE) {
            throw new InvalidArgumentException("The value \"$ip\" is not a valid IP address.");
        }

        if (strpos($ip, '.') !== FALSE && !$this->supportV4()) {
            throw new InvalidArgumentException("The Database not support IPv4 address.");
        }

        if (strpos($ip, ':') !== FALSE && !$this->supportV6()) {
            throw new InvalidArgumentException("The Database not support IPv6 address.");
        }

        try {
            $node = $this->findNode($ip);

            if ($node > 0) {
                $data = $this->resolve($node);

                $values = explode("\t", $data);

                return array_slice($values, $this->meta['languages'][$language], count($this->meta['fields']));
            }
        } catch (Exception $e) {
            return NULL;
        }

        return NULL;
    }

    /**
     * @param $ip
     * @param $language
     * @return array|false|null
     */
    public function findMap($ip, $language)
    {
        $array = $this->find($ip, $language);
        if (NULL === $array) {
            return NULL;
        }

        return array_combine($this->meta['fields'], $array);
    }

    /**
     * @var int
     */
    private $v4offset = 0;

    /**
     * @var array
     */
    private $v6offsetCache = [];

    /**
     * @param $ip
     * @return int
     * @throws Exception
     */
    private function findNode($ip): int
    {
        $binary = inet_pton($ip);
        $bitCount = strlen($binary) * 8; // 32 | 128
        $key = substr($binary, 0, 2);
        $node = 0;
        $index = 0;
        if ($bitCount === 32) {
            if ($this->v4offset === 0) {
                for ($i = 0; $i < 96 && $node < $this->nodeCount; $i++) {
                    if ($i >= 80) {
                        $idx = 1;
                    } else {
                        $idx = 0;
                    }
                    $node = $this->readNode($node, $idx);
                    if ($node > $this->nodeCount) {
                        return 0;
                    }
                }
                $this->v4offset = $node;
            } else {
                $node = $this->v4offset;
            }
        } else {
            if (isset($this->v6offsetCache[$key])) {
                $index = 16;
                $node = $this->v6offsetCache[$key];
            }
        }

        for ($i = $index; $i < $bitCount; $i++) {
            if ($node >= $this->nodeCount) {
                break;
            }

            $node = $this->readNode($node, 1 & ((0xFF & ord($binary[$i >> 3])) >> 7 - ($i % 8)));

            if ($i == 15) {
                $this->v6offsetCache[$key] = $node;
            }
        }

        if ($node === $this->nodeCount) {
            return 0;
        }

        if ($node > $this->nodeCount) {
            return $node;
        }

        throw new Exception("find node failed.");
    }

    /**
     * @param $node
     * @param $index
     * @return mixed
     * @throws Exception
     */
    private function readNode($node, $index)
    {
        return unpack('N', $this->read($this->file, $node * 8 + $index * 4, 4))[1];
    }

    /**
     * @param $node
     * @return mixed
     * @throws Exception
     */
    private function resolve($node)
    {
        $resolved = $node - $this->nodeCount + $this->nodeCount * 8;
        if ($resolved >= $this->fileSize) {
            return NULL;
        }

        $bytes = $this->read($this->file, $resolved, 2);
        $size = unpack('N', str_pad($bytes, 4, "\x00", STR_PAD_LEFT))[1];

        $resolved += 2;

        return $this->read($this->file, $resolved, $size);
    }

    /**
     *
     */
    public function close(): void
    {
        if (is_resource($this->file) === TRUE) {
            fclose($this->file);
        }
    }

    /**
     * @param $stream
     * @param $offset
     * @param $length
     * @return bool|string
     * @throws Exception
     */
    private function read($stream, $offset, $length)
    {
        if ($length > 0) {
            if (fseek($stream, $offset + $this->nodeOffset) === 0) {
                $value = fread($stream, $length);
                if (strlen($value) === $length) {
                    return $value;
                }
            }

            throw new Exception("The Database file read bad data.");
        }

        return '';
    }

    /**
     * @return bool
     */
    public function supportV6(): bool
    {
        return ($this->meta['ip_version'] & self::IPV6) === self::IPV6;
    }

    /**
     * @return bool
     */
    public function supportV4(): bool
    {
        return ($this->meta['ip_version'] & self::IPV4) === self::IPV4;
    }

    /**
     * @return array
     */
    public function getMeta()
    {
        return $this->meta;
    }

    /**
     * @return int  UTC Timestamp
     */
    public function getBuildTime()
    {
        return $this->meta['build'];
    }
}
