<?php

namespace kyle2142;

use function count;
use function in_array;
use InvalidArgumentException, LogicException, Exception, RuntimeException,
    CURLFile, stdClass;
use function is_resource;
use function is_string;
use function property_exists;

/**
 * Class PHPBot
 *
 * @package kyle2142
 */
class PHPBot
{
    private $webhook_reply_used = false, $token;
    public $api, $parse_mode = 'markdown';

    /**
     * PHPBot constructor.
     *
     * @param string $token The botAPI token provided by t.me/Botfather
     */
    public function __construct(string $token) {
        $this->token = $token;
        $this->api = new api($token);
    }

    /**
     * Executes a botAPI method as response to the webhook
     * which is faster than using action() but can only be used once per webhook call, and cannot retrieve result
     *
     * @param string $method The Telegram botAPI method to call
     * @param array $params Array of parameters needed for the method
     * @throws LogicException Thrown when called more than once per instantiation
     */
    public function quickAction($method, array $params = []) {
        if ($this->webhook_reply_used) {
            throw new LogicException('This function may only be called once per webhook call');
        }
        header_remove();
        header('Status: 200 OK', true, 200);
        header('Content-Type: application/json');
        $params['method'] = $method;
        echo json_encode($params);
        $this->webhook_reply_used = true;
    }

    /** Asks TG for updates (infinite loop) and uses the provided callable
     *
     * @param callable $handler the handler. Pass a string to call the function with that name. Must accept a parameter for the update array
     * @param int $offset
     * @param array $allowed_updates optional, types of updates to fetch
     * @param int $timeout optional, long polling timeout
     */
    public function handle_updates(callable $handler, int $offset = 0, array $allowed_updates = [], int $timeout = 20) {
        $options = ['offset' => $offset, 'timeout' => $timeout];
        if ($allowed_updates !== []) {
            $options['allowed_updates'] = $allowed_updates;
        }
        while (true) {
            $updates = $this->api->getUpdates($options);
            foreach ($updates as $update) {
                try {
                    $handler($update);
                    $options['offset'] = $update->update_id + 1;
                } catch (Exception $exception) {

                }
            }
        }
    }

    /**
     * Downloads file from TG, returning the bytes or saving to $destination
     *
     * @param string $file_id The file_id given by TG
     * @param string|resource $destination Can be a path (string) or a stream resource. When unspecified, the function will return the data
     * @return bool|string If $destination was set, returns whether the downloaded size matched the file_size given by TG, else returns the data
     */
    public function downloadFile(string $file_id, $destination = null) {
        $File = $this->api->getFile(['file_id' => $file_id]);
        $file_path = "https://api.telegram.org/file/bot{$this->token}/{$File->file_path}";
        if (is_string($destination)) {
            return file_put_contents($destination, $file_path) === $File->file_size;
        }
        $data = file_get_contents($file_path);
        if (is_resource($destination)) {
            return fwrite($destination, $data) === $File->file_size;
        }
        return $data;
    }

    /**
     * Send $text to $chat_id with optional extras such as reply_markup, see https://core.telegram.org/bots/api#sendmessage
     *
     * @param        $chat_id
     * @param string $text
     * @param array $extras Markdown is enabled by default
     * @return stdClass
     */
    public function sendMessage($chat_id, string $text, array $extras = []): stdClass {
        return $this->api->sendMessage(array_merge(['chat_id' => $chat_id, 'text' => $text], array_merge(['parse_mode' => $this->parse_mode], $extras)));
    }

    /**
     * Edits $msg_id from $chat_id to become $text, with optional $extras
     *
     * @param        $chat_id
     * @param int $msg_id
     * @param string $text
     * @param array $extras see https://core.telegram.org/bots/api#editmessagetext
     * @return stdClass|bool
     */
    public function editMessageText($chat_id, int $msg_id, string $text, array $extras = []) {
        return $this->api->editMessageText(array_merge(['chat_id' => $chat_id, 'message_id' => $msg_id, 'text' => $text], array_merge(['parse_mode' => $this->parse_mode], $extras)));
    }

    /**
     * Edits only reply_markup of $msg_id from $chat_id
     *
     * @param        $chat_id
     * @param int $msg_id
     * @param array $reply_markup The new reply_markup
     * @return mixed
     */
    public function editMarkup($chat_id, int $msg_id, array $reply_markup = []) {
        return $this->api->editMessageReplyMarkup(['chat_id' => $chat_id, 'message_id' => $msg_id, 'reply_markup' => $reply_markup]);
    }

    /**
     * Deletes $msg_id from $chat_id
     *
     * @param     $chat_id
     * @param int $msg_id
     * @return bool
     */
    public function deleteMessage($chat_id, int $msg_id): bool {
        return $this->api->deleteMessage(['chat_id' => $chat_id, 'message_id' => $msg_id]);
    }

    /**
     * Template function to edit/give $perms to $user_id in $chat_id
     *
     * @param int $user_id
     * @param       $chat_id
     * @param array $perms
     * @return bool
     */
    public function editAdmin(int $user_id, $chat_id, array $perms = []): bool {
        return $this->api->promotechatmember(
            array_merge(
                [
                    'user_id' => $user_id,
                    'chat_id' => $chat_id
                ],
                $perms
            )
        );
    }

    /**
     * Promotes user to full admin by default
     *
     * @param int $user_id
     * @param       $chat_id
     * @param array $perms
     * @return bool
     */
    public function promoteUser(int $user_id, $chat_id, array $perms = []): bool {
        if ($perms === []) {
            $perms = [
                'can_change_info' => 1,
                'can_delete_messages' => 1,
                'can_invite_users' => 1,
                'can_restrict_members' => 1,
                'can_pin_messages' => 1,
                'can_promote_members' => 1
            ];
        }
        return $this->editAdmin($user_id, $chat_id, $perms);
    }

    /**
     * Restricts user (forever by default) to be only able to read messages
     *
     * @param int $user_id
     * @param     $chat_id
     * @param int $until
     * @return bool
     */
    public function muteUser(int $user_id, $chat_id, int $until = 0): bool {
        return $this->api->restrictChatMember([
            'chat_id' => $chat_id,
            'user_id' => $user_id,
            'can_send_messages' => false,
            'until_date' => $until
        ]);
    }

    /**
     * Gives {delete/pin messages, invite users} permissions to $user_id in $chat_id
     *
     * @param int $user_id
     * @param     $chat_id
     * @return bool
     */
    public function makeModerator(int $user_id, $chat_id): bool {
        return $this->editAdmin($user_id, $chat_id,
            [
                'can_delete_messages' => 1,
                'can_invite_users' => 1,
                'can_pin_messages' => 1
            ]
        );
    }

    /**
     * Removes all admin permissions of $user_id in $chat_id
     *
     * @param int $user_id
     * @param     $chat_id
     * @return bool
     */
    public function demote(int $user_id, $chat_id): bool {
        return $this->editAdmin($user_id, $chat_id); //no args means no perms
    }

    //begin totally custom methods

    /**
     * Checks what privileges the bot has inside $chat_id
     *
     * @param $chat_id
     * @return stdClass
     */
    public function getPermissions($chat_id): stdClass {
        try {
            $api_reply = $this->api->getChatMember(['chat_id' => $chat_id, 'user_id' => $this->getBotID()]);
        } catch (TelegramException $e) {
            $api_reply = new stdClass();
            $api_reply->error_code = $e->getCode();
        }
        if (property_exists($api_reply, 'error_code')) {
            switch ($api_reply->error_code) {
                case 403: //forbidden
                    $api_reply->status = 'banned';
                    break;
                case 400: //chat not found
                    $api_reply->status = 'invalid';
                    break;
            }
        }
        if ($api_reply->status !== 'administrator') {
            $admin_perms = [
                'can_change_info',
                'can_delete_messages',
                'can_invite_users',
                'can_restrict_members',
                'can_pin_messages',
                'can_promote_members'
            ];
            foreach ($admin_perms as $perm) {
                $api_reply->$perm = false;
            }
        }
        if ($api_reply->status !== 'restricted') {
            $restricted_perms = [
                'can_send_messages',
                'can_send_media_messages',
                'can_send_other_messages',
                'can_add_web_page_previews'
            ];
            $in_group = !in_array($api_reply->status, ['left', 'banned', 'invalid']); //false if the bot isn't in the chat
            foreach ($restricted_perms as $perm) {
                $api_reply->$perm = $in_group;
            }
        }
        return $api_reply;
    }

    /**
     * Edits info of group, using any info given
     *
     * @param       $chat_id
     * @param array $info At least one of {title, description, photo path} must be in this array
     * @return array
     */
    public function editInfo($chat_id, array $info): array {
        if (count($info) < 1) {
            return [null];
        }
        $results = [];
        if (isset($info['title'])) {
            $results[] = $this->api->setChatTitle(['chat_id' => $chat_id, 'title' => $info['title']]);
        }
        if (isset($info['description'])) {
            $results[] = $this->api->setChatDescription(['chat_id' => $chat_id, 'description' => $info['description']]);
        }
        if (isset($info['photo']) && file_exists($info['photo'])) {
            $data = [
                'chat_id' => $chat_id,
                'photo' => new CURLFile(realpath($info['photo']))
            ];
            $results[] = $this->api->setChatPhoto($data);
        }
        return $results;
    }

    /**
     * Call $method with $params, 100ms timeout and not care about response/errors
     * @param string $method
     * @param array $params
     */
    public function fireAndForget(string $method, array $params = []) {
        $this->api->fireAndForget($method, $params);
    }

    /**
     * Takes a list of entities and restores markdown in $msg using botAPI format.
     *
     * Example usage: get message, pass to this function and get back original message before sending,
     * so it can be resent.
     *
     * @param string $msg
     * @param array $entities
     * @return string
     */
    public static function createMarkdownFromEntities(string $msg, array $entities): string {
        $dict = ['bold' => '*', 'italic' => '_', 'code' => '`', 'pre' => '```'];
        foreach (array_reverse($entities) as $e) { //edit in reverse order to preserve offsets
            switch ($e->type) {
                case 'bold':
                case 'italic':
                case 'code':
                case 'pre':
                    $msg = substr_replace($msg, $dict[$e->type], $e->offset + $e->length, 0);
                    $msg = substr_replace($msg, $dict[$e->type], $e->offset, 0); //0 means "insert"
                    break;
                case 'text_mention':
                    $e->url = 'tg://user?id=' . $e->user->id;
                case 'text_link':
                    $original = substr($msg, $e->offset, $e->length);
                    $msg = substr_replace($msg, "[$original]({$e->url})", $e->offset, $e->length);
                    break;
            }
        }
        return $msg;
    }

    /**
     * @return int
     */
    public function getBotID(): int {
        return $this->api->getBotID();
    }
}

/**
 * Handles contacting Telegram on PHPBot's behalf
 * Class api
 *
 * @package kyle2142
 */
class api
{
    const TIMEOUT = 62;
    private $BOTID, $curl, $token;
    public $api_host;

    public function __construct(string $token, string $api_host = "https://api.telegram.org") {
        if (preg_match('/^(\d+):[\w-]{30,}$/', $token, $matches) === 0) {
            throw new InvalidArgumentException('The supplied token does not look correct...');
        }
        $this->BOTID = (int)$matches[0];
        $this->token = $token;
        $this->api_host = $api_host;

        $this->curl = curl_init();
        curl_setopt($this->curl, CURLOPT_HTTPHEADER, ['Content-Type:multipart/form-data']);
        curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($this->curl, CURLOPT_TIMEOUT, $this::TIMEOUT); // botAPI might take 60s before returning error
    }

    public function __destruct() {
        curl_close($this->curl);
    }

    /**
     * Template function to make API calls using method name and array of parameters
     *
     * @param string $method The method name from https://core.telegram.org/bots/api
     * @param array $params The arguments of the method, as an array
     * @return stdClass|bool
     * @throws TelegramException, RuntimeException
     */
    public function __call(string $method, array $params) {
        curl_setopt($this->curl, CURLOPT_URL, "{$this->api_host}/bot{$this->token}/$method");
        curl_setopt($this->curl, CURLOPT_POSTFIELDS, $params[0] ?? []);
        $result = curl_exec($this->curl);
        if (curl_errno($this->curl)) {
            throw new RuntimeException(curl_error($this->curl), curl_errno($this->curl));
        }
        $object = json_decode($result);
        if (!$object->ok) {
            if (property_exists($object, 'parameters')) {
                if (property_exists($object->parameters, 'retry_after')) {
                    throw new TelegramFloodWait($object);
                }
                if (property_exists($object->parameters, 'migrate_to_chat_id')) {
                    throw new TelegramChatMigrated($object);
                }
            }
            throw new TelegramException($object);
        }
        return $object->result;
    }

    /**
     * @return int
     */
    public function getBotID(): int {
        return $this->BOTID;
    }

    public function fireAndForget(string $method, array $params = []) {
        curl_setopt($this->curl, CURLOPT_TIMEOUT_MS, 100);

        curl_setopt($this->curl, CURLOPT_URL, "{$this->api_host}/bot{$this->token}/$method");
        curl_setopt($this->curl, CURLOPT_POSTFIELDS, $params);
        curl_exec($this->curl);

        curl_setopt($this->curl, CURLOPT_TIMEOUT, $this::TIMEOUT);
    }
}

class TelegramException extends Exception
{
    protected $result;

    public function __construct(stdClass $result) {
        $this->result = $result;
        parent::__construct($result->description, $result->error_code);
    }

    public function __toString(): string {
        return get_class($this) . ": {$this->code} ({$this->message})\nTrace:\n{$this->getTraceAsString()}";
    }

    public function getResult(): stdClass {
        return $this->result;
    }
}

class TelegramFloodWait extends TelegramException
{
    protected $retry_after;

    public function __construct(stdClass $result) {
        $this->retry_after = $result->parameters->retry_after;
        parent::__construct($result);
    }

    public function getRetryAfter(): int {
        return $this->retry_after;
    }
}

class TelegramChatMigrated extends TelegramException
{
    protected $migrate_to_chat_id;

    public function __construct(stdClass $result) {
        $this->migrate_to_chat_id = $result->parameters->migrate_to_chat_id;
        parent::__construct($result);
    }

    public function getMigrateToChatId(): int {
        return $this->migrate_to_chat_id;
    }
}