<?php

namespace api\classes;

use DateTime;
use JetBrains\PhpStorm\NoReturn;

class API
{
    public mixed $conn;
    public mixed $pdo;

    # The user uuid that requested the API
    protected $user_uuid;

    # $user_type is either an API call (api) or a call from the frontend (frontend)
    protected $user_type;

    # Either GET POST PUT or DELETE
    public $request_method;

    protected $content_type;

    # The original posted data to the API, this data is NOT sanitized and validated, never use this data for queries!
    public mixed $postedData;

    # The validated and sanitized data can be uses for the API actions
    public $data;

    # The permission of the user to check if the action is allowed.
    public $permissions;

    # The return url that the frontend request will forward to after the api call is done. if set to false it will only output
    # the json response with an http code. API calls always respond with json. $return_url can be set to supply the form with an input
    # with the name _return and value of the url to return to.
    public mixed $return_url;

    # Used for the query builder base
    public $baseQuery = false;

    # Used for the general get function, this prevents SQL injecting by only allowing these columns instead of
    # Whatever the user inputs straight into the query.
    public $allowedGetColumns = false;

    # Cache the columns of the tables for some small performance improvements.
    private static array $columnCache = [];

    public function __construct()
    {
        require_once "{$_SERVER['DOCUMENT_ROOT']}/bin/php/db_connect.php";
        require_once "{$_SERVER['DOCUMENT_ROOT']}/bin/php/Functions/globalFunctions.php";

        # Setup Database connection
        $this->conn = $GLOBALS['conn'];
        $this->pdo = $GLOBALS['pdo'];

        if (!empty($_SESSION['user']['user_uuid'])) {
            $this->InitUserTypeFrontend();
        } else {
            $this->InitUserTypeAPI();
        }

        $this->return_url = $this->setReturnUrl();

        # user_uuid will be set if the user is authorized
        if (!$this->user_uuid) {
            $this->apiOutput(401, ['error' => 'Unauthorized']);
        }

        # Only allow POST, GET, PUT and DELETE
        if (!$this->checkRequestMethod()) {
            $this->apiOutput(405, ['error' => 'Method not allowed']);
        }


        if (!$this->checkContentType()) {
            $this->apiOutput(400, ['error' => 'Unsupported Content-Type.']);
        }

        if ($this->content_type === 'application/json') {
            if (!$this->checkJson()) {
                $this->apiOutput(400, ['error' => 'Invalid JSON format']);
            }
        }

        # Get the enabled modules for the user
        $GLOBALS['modules_enabled'] = getEnabledModules();

        // Disable builder input for non-GET requests to prevent potential SQL injection vulnerabilities.
        // Also disable the builder for users with the 'frontend' user type as an extra security measure.
        // The builder should only be active for API users making GET requests.
        // When building a frontend page, you can still programmatically construct a builder array
        // and set it via $_GET like so after the API class creation:
        // $_GET['builder'] = [1 => ['where' => [0 => 'permission_uuid', 1 => $permission_uuid]]];
        if ($this->request_method !== 'GET') {
            $this->disableBuilder();
        }

        # This converts the posted data if needed to an PHP array
        $this->postedData = $this->processPostedData();

    }

    private function InitUserTypeFrontend(): void
    {
        $this->user_uuid = $_SESSION['user']['user_uuid'];
        $this->user_type = 'frontend';

        # Load the locale for the user, this is used for the return message in the frontend and other globalFunctions.
        $locale = getPreferredLocale();
        global $translations;
        $translations = require $_SERVER['DOCUMENT_ROOT'] . "/bin/locales/{$locale}.php";
    }

    protected function RecursiveDeleteFolder($folderPath): bool
    {
        // Check if the folder exists
        if (!is_dir($folderPath)) {
            $this->apiOutput(500, ['error' => 'directory not found: ' . $folderPath]);
        }

        // Get all files and folders in the directory
        $items = array_diff(scandir($folderPath), array('.', '..'));

        // Loop through each item
        foreach ($items as $item) {

            $itemPath = $folderPath . DIRECTORY_SEPARATOR . $item;

            if (is_dir($itemPath)) {
                if (!$this->RecursiveDeleteFolder($itemPath)) {
                    $this->apiOutput(500, ['error' => "Unable to remove directory: $itemPath"]);
                }
            } else {
                if (!unlink($itemPath)) {
                    $this->apiOutput(500, ['error' => "Unable to delete file: $itemPath"]);
                }
            }

        }

        // Remove the main folder after all contents are gone
        if (!rmdir($folderPath)) {
            $this->apiOutput(500, ['error' => "Unable to remove directory: $folderPath"]);
        }

        return true;
    }

    private function InitUserTypeAPI()
    {
        $this->user_type = 'api';

        $headers = getallheaders();
        $authHeader = $headers['Authorization'] ?? '';

        if (!preg_match('/^Bearer\s+(.+)$/', $authHeader, $matches)) {
            $this->apiOutput(401, ['error' => 'Unauthorized, missing bearer token.']);
        }

        $bearerToken = trim($matches[1]);

        if (!preg_match('/^[a-f0-9\-]{36}\.[a-f0-9]{64}$/i', $bearerToken)) {
            $this->apiOutput(401, ['error' => 'Unauthorized, invalid token format.']);
        }

        [$tokenId, $tokenSecret] = explode('.', $bearerToken, 2);

        $this->user_uuid = $this->validateToken($tokenId, $tokenSecret);

        if ($this->user_uuid === false) {
            $this->apiOutput(401, ['error' => 'Unauthorized, invalid or expired token.']);
        }

        $api_token_last_used_timestamp = time();
        $stmt = $this->conn->prepare("UPDATE system_api_tokens SET api_token_last_used_timestamp = ? WHERE api_token_uuid = ?");
        $stmt->bind_param("is", $api_token_last_used_timestamp, $tokenId);
        $stmt->execute();
    }

    public function validateSingleData($value, $rules)
    {
        if (!$this->validateField($value, $rules)) {
            $this->apiOutput(400, ['error' => "Invalid value: $value"]);
        }

        return $this->sanitizeData($value, $rules['type']);
    }

    public function validateData($requiredFields, $optionalFields = [])
    {
        $inputData = $this->postedData;

        $sanitizedData = [];

        foreach ($requiredFields as $field => $rules) {

            if (!array_key_exists($field, $inputData)) {
                $this->apiOutput(400, ['error' => "Missing required field: $field"]);
            }

            $value = $inputData[$field];

            if (!$this->validateField($value, $rules)) {
                $this->apiOutput(400, ['error' => "Invalid value for $field"]);
            }

            $sanitizedData[$field] = $this->sanitizeData($value, $rules['type']);
        }


        // Check optional fields
        foreach ($optionalFields as $field => $rules) {
            if (isset($inputData[$field])) {
                $value = $inputData[$field];

                if (!$this->validateField($value, $rules)) {
                    $this->apiOutput(422, ['error' => "Invalid value for optional field: $field"]);
                }

                $sanitizedData[$field] = $this->sanitizeData($value, $rules['type']);
            }
        }

        if (isset($_GET['builder']) && is_array($_GET['builder'])) {
            foreach ($_GET['builder'] as $builder) {
                if (!isset($builder['where']) || count($builder['where']) !== 2) {
                    continue; // skip invalid builders
                }

                $field = $builder['where'][0];
                $value = $builder['where'][1];

                $rules = $requiredFields[$field] ?? $optionalFields[$field] ?? null;
                if (!$rules) {
                    $this->apiOutput(403, ['error' => "Field not allowed in query: $field"]);
                }

                if (!$this->validateField($value, $rules)) {
                    $this->apiOutput(422, ['error' => "Invalid value for builder field: $field"]);
                }

                $sanitizedData[$field] = $this->sanitizeData($value, $rules['type']);
            }
        }

        $this->data = $sanitizedData;
    }

    private function isValidLength($value, $rules)
    {
        $length = strlen($value);
        if (isset($rules['min']) && $length < $rules['min']) return false;
        if (isset($rules['max']) && $length > $rules['max']) return false;
        return true;
    }

    private function isValidNumberRange($value, $rules)
    {
        if (isset($rules['min']) && $value < $rules['min']) return false;
        if (isset($rules['max']) && $value > $rules['max']) return false;
        return true;
    }

    private function validateField($value, $rules)
    {
        switch ($rules['type']) {
            case 'string':
            case 'password':
            case 'html':
                if (!is_string($value)) return false;
                return $this->isValidLength($value, $rules);

            case 'slugify':
                if (!is_string($value) || !preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $value)) return false;
                return $this->isValidLength($value, $rules);

            case 'boolean':
                if (is_bool($value)) return true;

                if (is_string($value)) {
                    $value = strtolower($value);
                    return $value === 'true' || $value === 'false' || $value === '1' || $value === '0';
                }

                if (is_int($value)) {
                    return $value === 1 || $value === 0;
                }

                return false;

            case 'email':
                if (!is_string($value)) return false;
                if (!filter_var($value, FILTER_VALIDATE_EMAIL)) return false;
                return $this->isValidLength($value, $rules);

            case 'int':
                if (!is_int($value) && !ctype_digit($value)) return false;
                $value = (int)$value;
                return $this->isValidNumberRange($value, $rules);

            case 'float':
                // Accept floats or numeric strings
                if (!is_float($value) && !is_numeric($value)) {
                    return false;
                }
                $value = (float)$value;
                return $this->isValidNumberRange($value, $rules);

            case 'timestamp':
                if (is_null($value)) return true;
                if (!is_int($value) && !ctype_digit($value)) return false;
                $value = (int)$value;
                if ($value < 0) return false;
                $min = $rules['min'] ?? 1;
                $max = $rules['max'] ?? 4102444800;
                return $value >= $min && $value <= $max;

            case 'enum':
                if (!isset($rules['values']) || !in_array($value, $rules['values'], true)) return false;
                return true;

            case 'uuid':
                if (!is_string($value)) return false;
                return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value);

            case 'base64':
                if (!is_string($value)) return false;
                return base64_encode(base64_decode($value, true)) === $value;

            case 'json':
                if (!is_string($value)) return false;
                return json_validate($value);

            case 'array':
                if (!is_array($value)) return false;
                return $value;

            case 'date':
                if (!is_string($value)) return false;
                if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
                    return false;
                }
                [$year, $month, $day] = explode('-', $value);
                return checkdate((int)$month, (int)$day, (int)$year);

            default:
                return false;
        }
    }

    private function sanitizeData($value, $type)
    {
        switch ($type) {
            case 'string':
            case 'enum':
            case 'uuid':
                // Remove HTML tags and encode special characters
                return htmlspecialchars(strip_tags($value), ENT_QUOTES, 'UTF-8');

            case 'email':
                // Remove illegal characters from email address
                return filter_var($value, FILTER_SANITIZE_EMAIL);

            case 'password':
                // Passwords may contain special characters; just trim spaces
                return trim($value);

            case 'html':
                // Allow safe HTML, you can customize allowed tags
                return strip_tags($value, '<b><i><u><strong><em><p><br>');

            case 'int':
                // Remove anything that's not a number
                return filter_var($value, FILTER_SANITIZE_NUMBER_INT);

            case 'base64':
                // Only allow base64 valid characters
                return preg_replace('/[^a-zA-Z0-9\/\+=]/', '', $value);

            case 'boolean':
                if (is_string($value)) {
                    $value = strtolower(trim($value));
                    return in_array($value, ['true', '1'], true);
                }

                return (bool)$value;

            default:
                // Return as-is if unknown type
                return $value;
        }

    }

    public function checkPermissions($permission_name, $accessRightsRequired, $returnBoolean = false)
    {
        $accessLevels = [
            'NA' => 0, // No Access
            'RO' => 1, // Read Only
            'RW' => 2, // Read Write
        ];
        $query = "SELECT 
	                    system_permissions.permission_name,	
	                    system_user_group_permissions.permission_value
                    FROM system_user_group_permissions 
                        INNER JOIN system_permissions ON system_user_group_permissions.permission_uuid = system_permissions.permission_uuid
                        INNER JOIN system_users ON system_user_group_permissions.user_group_uuid = system_users.user_group_uuid
                    WHERE user_uuid = ? AND permission_name = ?";


        $stmt = $this->conn->prepare($query);
        $stmt->bind_param("ss", $this->user_uuid, $permission_name);
        $stmt->execute();
        $result = $stmt->get_result()->fetch_assoc();

        if (!$result) {
            if ($returnBoolean) {
                return false;
            }
            $this->apiOutput(500, ['error' => 'Did not find permission required']);
        }

        $userAccess = $result['permission_value'];

        if (!isset($accessLevels[$userAccess]) || !isset($accessLevels[$accessRightsRequired])) {
            if ($returnBoolean) {
                return false;
            }
            $this->apiOutput(500, ['error' => 'Server error.']);
        }

        // Compare user's access level with the required access level
        if ($accessLevels[$userAccess] < $accessLevels[$accessRightsRequired]) {
            if ($returnBoolean) {
                return false;
            }
            $this->apiOutput(403, ['error' => 'Permission denied. You do not have the required access level.']);
        }

        if ($returnBoolean) {
            return true;
        }

        return false;
    }

    protected function setReturnUrl()
    {
        if ($this->user_type !== 'frontend') {
            return false;
        }

        $method = $_SERVER['REQUEST_METHOD'];

        if ($method === 'POST' && isset($_POST['_return'])) {
            return $_POST['_return'];
        }

        if ($method === 'PUT') {
            parse_str(file_get_contents("php://input"), $putData);
            if (isset($putData['_return'])) {
                return $putData['_return'];
            }
        }
        if ($method === 'GET') {
            return false;
        }

        return $_SERVER['HTTP_REFERER'];
    }

    protected function checkRequestMethod()
    {
        $allowedMethods = ['GET', 'POST', 'PUT', 'DELETE'];
        $method = $_SERVER['REQUEST_METHOD'] ?? '';

        # The X-HTTP-Method-Override header is allowed for API requests because
        # some web servers do not support PUT or DELETE methods by default.
        # This override is only applied when the request method is POST and the header is present.
        if ($method === 'POST') {
            # $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] is from API calls
            # $_POST['X-HTTP-Method-Override'] if from API calls from the frontend.
            $override = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? $_POST['X-HTTP-Method-Override'] ?? null;

            if (is_string($override)) {
                $override = strtoupper($override);

                if (in_array($override, $allowedMethods, true)) {
                    $method = $override;
                }
            }
        }

        if (!in_array($method, $allowedMethods, true)) {
            return false;
        }

        $this->request_method = $method;
        return true;
    }

    protected function checkJson()
    {
        $rawInput = file_get_contents('php://input');
        if (empty($rawInput)) {
            return false;
        }

        if (!json_validate($rawInput)) {
            return false;
        }

        return true;
    }

    protected function processPostedData()
    {
        if ($this->user_type === 'api') {
            return json_decode(file_get_contents("php://input"), true);
        }

        switch ($this->request_method) {
            case 'GET':
                return $_GET;
            case 'POST':
                return $_POST;
            case 'PUT':
            case 'DELETE':
                # When an image is uploaded from the front end the data needs to be specified its in $_POST and not $_FILES
                if ($this->content_type === 'multipart/form-data') {
                    return $_POST;
                } else {
                    parse_str(file_get_contents("php://input"), $data);
                    return $data;
                }

            default:
                return [];
        }
    }

    protected function validateToken(string $tokenId, string $tokenSecret)
    {
        $stmt = $this->conn->prepare("SELECT user_uuid, api_token FROM system_api_tokens WHERE api_token_uuid = ? AND api_token_expiration_timestamp > UNIX_TIMESTAMP()");
        $stmt->bind_param("s", $tokenId);
        $stmt->execute();

        $row = $stmt->get_result()->fetch_assoc();

        if (!$row) {
            return false;
        }

        if (!password_verify($tokenSecret, $row['api_token'])) {
            return false;
        }

        return $row['user_uuid'];
    }


    protected function checkContentType()
    {
        # api will need to post with an application/json type Content type.
        # frontend will post with application/x-www-form-urlencoded content type but also is capable of application/json
        # frontend can also post multipart/form-data
        # GET requests don't have a content type

        $contentType = $_SERVER['CONTENT_TYPE'] ?? '';

        if ($this->request_method === 'GET') {
            $this->content_type = '';
            return true;
        }

        if ($this->user_type === 'api') {
            $this->content_type = 'application/json';
            return true;
        }

        if (str_contains($contentType, 'application/json')) {
            $this->content_type = 'application/json';
            return true;
        }

        if (str_contains($contentType, 'application/x-www-form-urlencoded')) {
            $this->content_type = 'application/x-www-form-urlencoded';
            return true;
        }

        if (str_contains($contentType, 'multipart/form-data')) {
            $this->content_type = 'multipart/form-data';
            return true;
        }

        return false;
    }

    public function getUserUuid()
    {
        return $this->user_uuid;
    }

    #[NoReturn]
    public function apiOutput($code = 200, $data = [], $frontendMessage = false)
    {
        if ($this->user_type === 'api') {
            http_response_code($code);
            header('Content-Type: application/json');
            echo json_encode($data);
            exit;
        }


        if ($this->user_type === 'frontend') {
            if (in_array($this->request_method, ['POST', 'PUT', 'DELETE'])) {
                http_response_code($code);

                if ($this->return_url) { # sometimes the PUT doesn't need a return or response set (Think of js actions to api from frontend)
                    $_SESSION['response'] = json_encode($data);

                    # When a request is successfully the api will receive the data, the frontend needs a friendly message
                    if ($frontendMessage) {
                        $_SESSION['response'] = json_encode([key($data) => __($frontendMessage)]);
                    }

                    header('Location: ' . $this->return_url);
                    exit;
                }
            }

            header('Content-Type: application/json');
            echo json_encode($data);
            exit;
        }

        exit;
    }

    public function prepareStatement($query)
    {
        // Enable MySQLi to throw exceptions on errors
        mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

        try {
            $stmt = $this->conn->prepare($query);

        } catch (\mysqli_sql_exception $e) {
            // If an error occurs during prepare, catch it and return a proper response
            $this->apiOutput(500, ['error' => 'Database error: ' . $e->getMessage()]);
        }

        return $stmt;

    }

    public function executeStatement($stmt)
    {
        mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

        try {
            $stmt->execute();
            return true;
        } catch (\mysqli_sql_exception $e) {
            if ($e->getCode() === 1451) {
                $this->apiOutput(409, ['error' => 'Cannot delete record: dependent data exists.']);
            } else {
                $this->apiOutput(500, ['error' => 'Database error: ' . $e->getMessage()]);
            }
        }
    }

    public function isSuperuser()
    {
        $query = "SELECT * FROM system_users WHERE system_users.user_uuid = ?";
        $stmt = $this->prepareStatement($query);
        $stmt->bind_param('s', $this->user_uuid);
        $this->executeStatement($stmt);
        $result = $stmt->get_result();
        $user_data = $result->fetch_assoc();
        if ($user_data['user_email'] == 'superuser') {
            return true;
        } else {
            return false;
        }
    }

    protected function isValidDate(string $date): bool
    {
        $dt = DateTime::createFromFormat('Y-m-d', $date);
        return $dt && $dt->format('Y-m-d') === $date;
    }

    protected function buildDynamicQuery(string $tableName): array
    {
        $this->allowedGetColumns = $this->loadTableColumns($tableName);

        if (!$this->baseQuery) {
            $this->baseQuery = "SELECT * FROM " . $tableName;
        }

        $whereClauses = [];
        $types = '';
        $values = [];

        if (!isset($_GET['builder']) || !is_array($_GET['builder'])) {
            return [$this->baseQuery, $types, $values];
        }

        foreach ($_GET['builder'] as $builder) {
            if (isset($builder['where']) && is_array($builder['where'])) {

                if (count($builder['where']) !== 2) {
                    continue;
                }

                $column = $builder['where'][0];
                $value = $builder['where'][1];

                if (!in_array($column, $this->allowedGetColumns, true)) {
                    $this->apiOutput(400, ['error' => "The column $column is not allowed."]);
                }

                $whereClauses[] = "$column = ?";
                $types .= 's';
                $values[] = $value;
            }

            // BETWEEN (for DATE / range fields)
            if (isset($builder['between']) && is_array($builder['between'])) {

                if (count($builder['between']) !== 3) {
                    continue;
                }

                $column = $builder['between'][0];
                $from = $builder['between'][1];
                $to = $builder['between'][2];

                if (!in_array($column, $this->allowedGetColumns, true)) {
                    $this->apiOutput(400, ['error' => "The column $column is not allowed."]);
                }

                if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
                    $this->apiOutput(400, ['error' => "Invalid date format for BETWEEN."]);
                }

                $whereClauses[] = "$column BETWEEN ? AND ?";
                $types .= 'ss';
                $values[] = $from;
                $values[] = $to;
            }

            // ORDER BY
            if (isset($builder['orderBy']) && is_array($builder['orderBy'])) {

                if (count($builder['orderBy']) !== 2) {
                    continue;
                }

                $column = $builder['orderBy'][0];
                $direction = strtoupper($builder['orderBy'][1]);

                if (!in_array($column, $this->allowedGetColumns, true)) {
                    $this->apiOutput(400, ['error' => "The column $column is not allowed."]);
                }

                if (!in_array($direction, ['ASC', 'DESC'], true)) {
                    $this->apiOutput(400, ['error' => "Invalid order direction."]);
                }

                $orderByClauses[] = "$column $direction";
            }

        }

        if (!empty($whereClauses)) {
            $this->baseQuery .= " WHERE " . implode(" AND ", $whereClauses);
        }

        if (!empty($orderByClauses)) {
            $this->baseQuery .= " ORDER BY " . implode(", ", $orderByClauses);
        }

        return [$this->baseQuery, $types, $values];
    }

    protected function loadTableColumns(string $tableName): array
    {
        if (isset(self::$columnCache[$tableName])) {
            return self::$columnCache[$tableName];
        }

        $sql = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?";

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$tableName]);

        return self::$columnCache[$tableName] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
    }

    protected function generalGetFunction($query, $types, $params, $returnBoolean, $itemName)
    {
        $stmt = $this->prepareStatement($query);

        if (!empty($params)) {
            $stmt->bind_param($types, ...$params);
        }

        $this->executeStatement($stmt);
        $result = $stmt->get_result();

        if ($result->num_rows === 0) {
            if (!$returnBoolean) {
                $this->apiOutput(404, ['error' => $itemName . ' not found.']);
            }
        }

        $tokens = [];

        while ($row = $result->fetch_assoc()) {
            $tokens[] = $row;
        }

        return $tokens;
    }

    public function disableBuilder(): void
    {
        if (isset($_GET['builder'])) {
            unset($_GET['builder']);
        }
    }


}