<?php

namespace app\AdminActions\Util;

use ReflectionClass;
use ReflectionProperty;
use app\AdminActions\BaseAction;

class Logger
{
    const TABLE = 'admin_actions';

    const int INTS_COUNT = 6;
    const string INTS_PREFIX = 'i';

    const int VARCHARS_COUNT = 2;
    const string VARCHARS_PREFIX = 'c';

    const int SERIALIZED_COUNT = 1;
    const string SERIALIZED_PREFIX = 's';

    protected static ?array $classes = null;

    public static function record(BaseAction $action): int {
        $packed = self::pack($action);

        $data = [
            'action' => $action::getActionId(),
            'ts' => time(),
        ];

        if (isCli()) {
            $data += [
                'cli' => 1,
            ];
        } else {
            $data += [
                'admin_id' => \app\Admin::getId(),
                'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0,
            ];
        }

        foreach ($packed as $prefix => $args) {
            foreach ($args as $i => $arg) {
                $name = $prefix.'arg'.($i+1);
                $data[$name] = $arg;
            }
        }

        $db = getDB();
        $db->insert(self::TABLE, $data);

        return $db->insertId();
    }

    public static function getRecordById(int $id): ?BaseAction {
        $db = getDB();
        $q = $db->query("SELECT * FROM ".self::TABLE." WHERE id=?", $id);
        if (!$db->numRows($q))
            return null;
        return self::unpack($db->fetch($q));
    }

    public static function getRecordsCount(?array $admin_types = null,
                                           ?array $actions = null,
                                           ?array $arguments = null): int {
        $db = getDB();
        $sql = "SELECT COUNT(*) FROM ".self::TABLE;
        $where = self::getSQLSelectConditions($admin_types, $actions, $arguments);
        if ($where != '')
            $sql .= " WHERE ".$where;
        $q = $db->query($sql);
        return (int)$db->result($q);
    }

    /**
     * @param int $offset
     * @param int $count
     * @param array|null $admin_types
     * @param array|null $actions
     * @param array|null $arguments
     * @return BaseAction[]
     */
    public static function getRecords(int $offset,
                                      int $count,
                                      ?array $admin_types = null,
                                      ?array $actions = null,
                                      ?array $arguments = null): array {
        $db = getDB();
        $sql = "SELECT * FROM ".self::TABLE;
        $where = self::getSQLSelectConditions($admin_types, $actions, $arguments);
        if ($where != '')
            $sql .= " WHERE ".$where;
        $sql .= " ORDER BY ts DESC";
        $sql .= " LIMIT $offset, $count";
        return array_map(self::class.'::unpack', $db->fetchAll($db->query($sql)));
    }

    /**
     * @param int $user_id
     * @param int|null $time_from
     * @param int|null $time_to
     * @return BaseAction[]
     */
    public static function getUserRecords(int $user_id, ?int $time_from, ?int $time_to): array {
        $db = getDB();
        $sql = "SELECT * FROM ".self::TABLE." WHERE admin_id={$user_id}";
        if ($time_from && $time_to)
            $sql .= " AND ts BETWEEN {$time_from} AND {$time_to} ";
        $sql .= "ORDER BY ts";
        return array_map(self::class.'::unpack', $db->fetchAll($db->query($sql)));
    }

    protected static function getSQLSelectConditions(?array $admin_types = null,
                                                     ?array $actions = null,
                                                     ?array $arguments = null): string {
        $wheres = [];
        $db = getDB();

        if (!empty($admin_types))
            $wheres[] = "admin_type IN ('".implode("', '", $admin_types)."')";

        if (!empty($actions)) {
            $actions = array_map(
                fn(BaseAction|int $action) => $action instanceof BaseAction ? $action::getActionId() : $action,
                $actions);
            $wheres[] = "action IN (".implode(',', $actions).")";
        }

        if (!empty($arguments)) {
            foreach ($arguments as $k => $v) {
                if (!str_starts_with($k, 'iarg') && !str_starts_with($k, 'carg') && !str_starts_with($k, 'sarg')) {
                    logError(__METHOD__.': filter argument '.$k.' not supported');
                    continue;
                }
                $wheres[] = $v === null ? $k."=NULL" : $k."='".$db->escape($v)."'";
            }
        }

        return !empty($wheres) ? "(".implode(") AND (", $wheres).")" : '';
    }

    public static function pack(BaseAction $action): array {
        $field_types = self::getFieldTypes();
        $packed = [];
        $cl = get_class($action);
        $rc = new ReflectionClass($cl);
        $fields = $rc->getProperties(ReflectionProperty::IS_PUBLIC);
        foreach ($fields as $field) {
            $field_name = $field->getName();
            $refl_field = new ReflectionProperty($cl, $field_name);
            $refl_type = $refl_field->getType();
            if (!$refl_type->isBuiltin()) {
                logError(__METHOD__.': field "'.$field_name.'" is not of built-in type');
                continue;
            }

            switch ($refl_type->getName()) {
                case 'int':
                case 'bool':
                    $prefix = 'i';
                    break;
                case 'array':
                    $prefix = 's';
                    break;
                case 'string':
                    $prefix = 'c';
                    break;
                default:
                    logError(__METHOD__.': unexpected field type: '.$refl_type->getName());
                    break;
            }

            if (!isset($packed[$prefix]) || count($packed[$prefix]) < $field_types[$prefix]['count']) {
                $packed[$prefix][] = $field_types[$prefix]['packer']($action->{$field_name});
            } else {
                logError(__METHOD__.': max ['.$prefix.'] count ('.$field_types[$prefix]['count'].') exceeded');
            }
        }
        return $packed;
    }

    public static function unpack(array $data): BaseAction {
        $action_id = (int)$data['action'];
        $cl = self::getClassByActionId($action_id);

        $field_types = self::getFieldTypes();
        $counter = [];
        foreach ($field_types as $type => $tmp)
            $counter[$type] = 1;

        $rc = new ReflectionClass($cl);
        $arguments = [];
        $fields = $rc->getProperties(ReflectionProperty::IS_PUBLIC);
        foreach ($fields as $field) {
            $name = $field->getName();
            $refl_field = new ReflectionProperty($cl, $name);
            $refl_type = $refl_field->getType();
            if (!$refl_type->isBuiltin()) {
                logError(__METHOD__.': field "'.$name.'" is not of built-in type');
                continue;
            }

            switch ($refl_type->getName()) {
                case 'int':
                case 'bool':
                    $prefix = 'i';
                    break;
                case 'array':
                    $prefix = 's';
                    break;
                case 'string':
                    $prefix = 'c';
                    break;
                default:
                    logError(__METHOD__.': unexpected field type: '.$refl_type->getName());
                    break;
            }

            $val = $data[$prefix.'arg'.($counter[$prefix]++)] ?? null;
            if (!$refl_type->allowsNull() || !is_null($val))
                $val = $field_types[$prefix]['unpacker']($val);

            $arguments[] = $val;
        }

        /** @var BaseAction $obj */
        try {
            $obj = new $cl(...$arguments);
        } catch (\TypeError $e) {
            logDebug($arguments);
            logError($e);
            exit();
        }
        $obj->setMetaInformation((int)$data['id'], (int)$data['admin_id'], (int)$data['ts'], (int)$data['ip'], (bool)$data['cli']);

        return $obj;
    }

    public static function getActions(bool $only_names = false): array {
        if (is_null(self::$classes)) {
            $objects = [];
            $dir = realpath(__DIR__.'/../');
            $files = scandir($dir);
            foreach ($files as $f) {
                // skip non-files
                if ($f == '.' || $f == '..')
                    continue;

                $class_name = substr($f, 0, strpos($f, '.'));
                $class = '\\app\\AdminActions\\'.$class_name;

                if (interface_exists($class) || !class_exists($class)) {
                    // logError(__METHOD__.': class '.$class.' not found');
                    continue;
                }

                $parents = class_parents($class);
                $found = false;
                foreach ($parents as $p) {
                    if (str_ends_with($p, 'BaseAction')) {
                        $found = true;
                        break;
                    }
                }
                if (!$found) {
                    // logError(__METHOD__.': parent BaseAction not found in class '.$class);
                    continue;
                }

                $objects[$class::getActionId()] = $class;
            }
            self::$classes = $objects;
        }

        if (!$only_names)
            return self::$classes;

        return array_map(function(string $cl): string {
            if (($pos = strrpos($cl, '\\')) !== false)
                $cl = substr($cl, $pos+1);
            return $cl;
        }, self::$classes);
    }

    public static function getClassByActionId(int $action_id): string {
        return self::getActions()[$action_id];
    }

    public static function getFieldTypes(): array {
        static $types = [];
        if (!empty($types))
            return $types;

        foreach (['INTS', 'VARCHARS', 'SERIALIZED'] as $name) {
            $prefix = constant("self::{$name}_PREFIX");
            $count = constant("self::{$name}_COUNT");
            $types[$prefix] = ['count' => $count];
            switch ($prefix) {
                case 'i':
                    $types[$prefix]['unpacker'] = fn($v) => $v === null ? null : intval($v);
                    $types[$prefix]['packer'] = fn($v) => strval($v);
                    break;
                case 'c':
                    $types[$prefix]['unpacker'] = fn($v) => $v === null ? null : strval($v);
                    $types[$prefix]['packer'] = $types[$prefix]['unpacker'];
                    break;
                case 's':
                    $types[$prefix]['unpacker'] = fn($v) => $v === null ? null : unserialize($v);
                    $types[$prefix]['packer'] = fn($v) => serialize($v);
                    break;
            }
        }
        return $types;
    }
}
