<?php

namespace Core\API;

use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\TwoFactorToken;
use Core\Objects\RateLimiting;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use PhpMqtt\Client\MqttClient;

// TODO: many things are only checked for external calls, e.g. loginRequired. If we call the API internally, we might get null-pointers for $context->user
abstract class Request {

  protected Context $context;
  protected Logger $logger;
  protected array $params;
  protected string $lastError;
  protected array $result;
  protected bool $success;
  protected bool $isPublic;
  protected bool $loginRequired;
  protected bool $variableParamCount;
  protected bool $isDisabled;
  protected bool $apiKeyAllowed;
  protected bool $csrfTokenRequired;
  protected ?RateLimiting $rateLimiting;

  private array $defaultParams;
  private array $allowedMethods;
  private bool $externalCall;

  public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
    $this->context = $context;
    $this->logger = new Logger($this->getAPIName(), $this->context->getSQL());
    $this->defaultParams = $params;
    $this->externalCall = $externalCall;
    $this->variableParamCount = false;

    // result
    $this->lastError = "";
    $this->success = false;
    $this->result = array();

    // restrictions
    $this->isPublic = true;
    $this->isDisabled = false;
    $this->loginRequired = false;
    $this->apiKeyAllowed = true;
    $this->allowedMethods = array("GET", "POST");
    $this->csrfTokenRequired = true;
    $this->rateLimiting = null;
  }

  public function getAPIName(): string {
    if (get_class($this) === Request::class) {
      return "API";
    }

    $reflection = new \ReflectionClass($this);
    if ($reflection->getParentClass()->isAbstract() && $reflection->getParentClass()->isSubclassOf(Request::class)) {
      return $reflection->getParentClass()->getShortName() . "/" . $reflection->getShortName();
    } else {
      return $reflection->getShortName();
    }
  }

  protected function forbidMethod($method): void {
    if (($key = array_search($method, $this->allowedMethods)) !== false) {
      unset($this->allowedMethods[$key]);
    }
  }

  public function getDefaultParams(): array {
    return $this->defaultParams;
  }

  public function isDisabled(): bool {
    return $this->isDisabled;
  }

  protected function allowMethod($method): void {
    $availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
    if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
      $this->allowedMethods[] = $method;
    }
  }

  protected function getRequestMethod() {
    return $_SERVER["REQUEST_METHOD"];
  }

  public function parseParams($values, $structure = NULL): bool {

    if ($structure === NULL) {
      $structure = $this->params;
    }

    foreach ($structure as $name => $param) {
      $value = $values[$name] ?? NULL;

      $isEmpty = is_string($value) && strlen($value) === 0;
      if (!$param->optional && (is_null($value) || $isEmpty)) {
        return $this->createError("Missing parameter: $name");
      }

      $param->reset();
      if (!is_null($value) && !$isEmpty) {
        if (!$param->parseParam($value)) {
          $value = print_r($value, true);
          return $this->createError("Invalid Type for parameter: $name '$value' (Required: " . $param->getTypeName() . ")");
        }
      }
    }

    return true;
  }

  public function parseVariableParams($values): void {
    foreach ($values as $name => $value) {
      if (isset($this->params[$name])) continue;
      $type = Parameter\Parameter::parseType($value);
      $param = new Parameter\Parameter($name, $type, true);
      $param->parseParam($value);
      $this->params[$name] = $param;
    }
  }

  // wrapper for unit tests
  protected function _die(string $data = ""): bool {
    die($data);
  }

  protected abstract function _execute(): bool;

  public static abstract function getDescription(): string;

  public static function getDefaultPermittedGroups(): array {
    return [];
  }

  public static function hasConfigurablePermissions(): bool {
    return true;
  }

  protected function check2FA(?TwoFactorToken $tfaToken = null): bool {

    // do not require 2FA for verifying endpoints
    if ($this instanceof \Core\API\Tfa\VerifyTotp || $this instanceof \Core\API\Tfa\VerifyKey) {
      return true;
    }

    if ($tfaToken === null) {
      $tfaToken = $this->context->getUser()?->getTwoFactorToken();
    }

    if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {

      if ($tfaToken instanceof KeyBasedTwoFactorToken && !$tfaToken->hasChallenge()) {
        $tfaToken->generateChallenge();
      }

      $this->lastError = '2FA-Authorization is required';
      $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
        "type", "challenge", "authenticated", "confirmed", "credentialID"
      ]);
      return false;
    }

    return true;
  }

  protected function getCORS(): array {
    $settings = $this->context->getSettings();
    return $settings->getTrustedDomains();
  }

  public final function execute($values = array()): bool {

    if ($this->externalCall) {
      $trustedDomains = $this->getCORS();
      if (!empty($trustedDomains)) {
        // TODO: origins require a protocol, e.g. https:// or http:// as prefix. 
        // should we force https for all origins? or make exceptions for localhost?
        header("Access-Control-Allow-Origin: " . implode(", ", $trustedDomains));
      }
    }

    $this->params = array_merge([], $this->defaultParams);
    $this->success = false;
    $this->result = array();
    $this->lastError = '';

    $session = $this->context->getSession();
    if ($session) {
      $this->result['logoutIn'] = $session->getExpiresSeconds();
    }

    if ($this->isDisabled) {
      $this->lastError = "This function is currently disabled.";
      http_response_code(503);
      return false;
    }

    $sql = $this->context->getSQL();
    if ($sql === null || !$sql->isConnected()) {
      $this->lastError = $sql ? $sql->getLastError() : "Database not connected yet.";
      http_response_code(503);
      return false;
    }

    if ($this->externalCall) {
      if (!$this->isPublic) {
        $this->lastError = 'This function is private.';
        http_response_code(403);
        return false;
      }

      $values = $_REQUEST;
      if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) {
        $jsonData = json_decode(file_get_contents('php://input'), true);
        if ($jsonData !== null) {
          $values = array_merge($values, $jsonData);
        } else {
          $this->lastError = 'Invalid request body.';
          http_response_code(400);
          return false;
        }
      }

      if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
        http_response_code(204); # No content
        header("Allow: OPTIONS, " . implode(", ", $this->allowedMethods));
        return $this->_die();
      }

      // check the request method
      if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) {
        $this->lastError = 'This method is not allowed';
        http_response_code(405);
        return false;
      }

      $apiKeyAuthorized = false;
      if (!$session && $this->apiKeyAllowed) {
        if (isset($_SERVER["HTTP_AUTHORIZATION"])) {
          $authHeader = $_SERVER["HTTP_AUTHORIZATION"];
          if (startsWith($authHeader, "Bearer ")) {
            $apiKey = substr($authHeader, strlen("Bearer "));
            $apiKeyAuthorized = $this->context->loadApiKey($apiKey);
          }
        }
      }

      // Logged in or api key authorized?
      if ($this->loginRequired) {
        if (!$session && !$apiKeyAuthorized) {
          $this->lastError = 'You are not logged in.';
          $this->result["loggedIn"] = false;
          http_response_code(401);
          return false;
        } else if ($session && !$this->check2FA()) {
          http_response_code(401);
          return false;
        }
      }

      // CSRF Token
      if ($this->csrfTokenRequired && $session) {
        // csrf token required + external call
        // if it's not a call with API_KEY, check for csrfToken
        $csrfToken = $values["csrfToken"] ?? $_SERVER["HTTP_XSRF_TOKEN"] ?? null;
        if (!$csrfToken || strcmp($csrfToken, $session->getCsrfToken()) !== 0) {
          $this->lastError = "CSRF-Token mismatch";
          http_response_code(403);
          return false;
        }
      }

      // Check for permission
      $req = new \Core\API\Permission\Check($this->context);
      $this->success = $req->execute(["method" => self::getEndpoint()]);
      $this->lastError = $req->getLastError();
      if (!$this->success) {
        $res = $req->getResult();
        if (!$this->context->getUser()) {
          $this->result["loggedIn"] = false;
        } else if (isset($res["twoFactorToken"])) {
          $this->result["twoFactorToken"] = $res["twoFactorToken"];
        }
        return false;
      }
    }

    if (!$this->parseParams($values)) {
      return false;
    }

    if ($this->variableParamCount) {
      $this->parseVariableParams($values);
    }

    if ($this->externalCall && $this->rateLimiting) {
      $settings = $this->context->getSettings();
      if ($settings->isRateLimitingEnabled()) {
        if (!$this->rateLimiting->check($this->context, self::getEndpoint())) {
          http_response_code(429);
          $this->lastError = "Rate limit exceeded";
          return false;
        }
      }
    }

    $this->success = true;
    try {
      $success = $this->_execute();
      if ($this->success !== $success) {
        // _execute might return a different value then it set for $this->success
        // this should actually not occur, how to handle this case?
        $this->success = $success;
      }
    } catch (\Throwable $err) {
      http_response_code(500);
      $this->createError($err->getMessage());
      $this->logger->severe($err);
    }

    $sql->setLastError("");
    return $this->success;
  }

  protected function createError($err): bool {
    $this->success = false;
    $this->lastError = $err;
    return false;
  }

  protected function getParam($name, $obj = NULL): mixed {
    if ($obj === NULL) {
      $obj = $this->params;
    }

    // I don't know why phpstorm
    return (isset($obj[$name]) ? $obj[$name]->value : NULL);
  }

  public function isMethodAllowed(string $method): bool {
    return in_array($method, $this->allowedMethods);
  }

  public function isPublic(): bool {
    return $this->isPublic;
  }

  public function getLastError(): string {
    return $this->lastError;
  }

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

  public function success(): bool {
    return $this->success;
  }

  public function loginRequired(): bool {
    return $this->loginRequired;
  }

  public function isExternalCall(): bool {
    return $this->externalCall;
  }

  public static function getEndpoint(string $prefix = ""): ?string {
    $reflectionClass = new \ReflectionClass(get_called_class());
    if ($reflectionClass->isAbstract()) {
      return null;
    }

    $parentClass = $reflectionClass->getParentClass();
    if ($parentClass === false) {
      return null;
    }

    $isNestedAPI = $parentClass->getName() !== Request::class;
    if (!$isNestedAPI) {
      # e.g. /api/stats or /api/info
      $methodName = $reflectionClass->getShortName();
      return $prefix . lcfirst($methodName);
    } else if ($parentClass->getName() === \TestRequest::class) {
      $methodName = $reflectionClass->getShortName();
      return $prefix . "/e2e-test/" . lcfirst($methodName);
    } else {
      # e.g. /api/user/login
      $methodClass = $reflectionClass;
      $nestedClass = $reflectionClass->getParentClass();
      while (!endsWith($nestedClass->getName(), "API")) {
        $methodClass = $nestedClass;
        $nestedClass = $nestedClass->getParentClass();

        if (!$nestedClass) {
          return null;
        }
      }

      $nestedAPI = substr(lcfirst($nestedClass->getShortName()), 0, -3);
      $methodName = lcfirst($methodClass->getShortName());
      return $prefix . $nestedAPI . "/" . $methodName;
    }
  }

  public function getJsonResult(): string {
    $this->result['success'] = $this->success;
    $this->result['msg'] = $this->lastError;
    return json_encode($this->result);
  }

  protected function disableOutputBuffer(): void {
    ob_implicit_flush(true);
    $levels = ob_get_level();
    for ( $i = 0; $i < $levels; $i ++ ) {
      ob_end_flush();
    }
    flush();
  }

  protected function disableCache(): void {
    header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T"));
    header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
    header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
    header("Cache-Control: post-check=0, pre-check=0", false);
    header("Pragma: no-cache");
  }

  protected function setupSSE(): void {
    $this->context->sendCookies();
    $this->context->getSQL()?->close();
    set_time_limit(0);
    ignore_user_abort(true);
    header('Content-Type: text/event-stream');
    header('Connection: keep-alive');
    header('X-Accel-Buffering: no');
    $this->disableCache();
    $this->disableOutputBuffer();
  }

  /**
   * @throws \PhpMqtt\Client\Exceptions\ProtocolViolationException
   * @throws \PhpMqtt\Client\Exceptions\DataTransferException
   * @throws \PhpMqtt\Client\Exceptions\MqttClientException
   */
  protected function startMqttSSE(MqttClient $mqtt, callable $onPing): void {
    $lastPing = 0;
    $mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) {
      if ($elapsed - $lastPing >= 5) {
        $onPing();
        $lastPing = $elapsed;
      }

      if (connection_status() !== 0) {
        $mqtt->interrupt();
      }
    });

    $mqtt->loop();
    $this->lastError = "MQTT Loop disconnected";
    $mqtt->disconnect();
  }

  protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null): bool|array {
    if (empty($_FILES)) {
      return $this->createError("You need to upload an image.");
    } else if (count($_FILES) > 1) {
      return $this->createError("You can only upload one image at once.");
    }

    $upload = array_values($_FILES)[0];
    if (is_array($upload["name"])) {
      return $this->createError("You can only upload one image at once.");
    } else if ($upload["error"] !== UPLOAD_ERR_OK) {
      return $this->createError("There was an error uploading the image, code: " . $upload["error"]);
    }

    $imageName = $upload["name"];
    $ext = strtolower(pathinfo($imageName, PATHINFO_EXTENSION));
    if (!in_array($ext, $allowedExtensions)) {
      return $this->createError("Only the following file extensions are allowed: " . implode(",", $allowedExtensions));
    }

    if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true)) {
      return $this->createError("Upload directory does not exist and could not be created.");
    }

    $srcPath = $upload["tmp_name"];
    $mimeType = mime_content_type($srcPath);
    if (!startsWith($mimeType, "image/")) {
      return $this->createError("Uploaded file is not an image.");
    }

    try {
      $image = new \Imagick($srcPath);

      // strip exif
      $profiles = $image->getImageProfiles("icc", true);
      $image->stripImage();
      if (!empty($profiles)) {
        $image->profileImage("icc", $profiles['icc']);
      }
    } catch (\ImagickException $ex) {
      return $this->createError("Error loading image: " . $ex->getMessage());
    }

    try {
      if ($transformCallback) {
        $fileName = call_user_func([$this, $transformCallback], $image, $uploadDir);
      } else {

        $image->writeImage($srcPath);
        $image->destroy();

        $uuid = uuidv4();
        $fileName = "$uuid.$ext";
        $destPath = "$uploadDir/$fileName";
        if (!file_exists($destPath)) {
          if (!@move_uploaded_file($srcPath, $destPath)) {
            return $this->createError("Could not store uploaded file.");
          }
        }
      }

      return [$fileName, $imageName];
    } catch (\ImagickException $ex) {
      return $this->createError("Error processing image: " . $ex->getMessage());
    }
  }

  protected function getFileUpload(string $name, bool $allowMultiple = false, ?array $extensions = null): false|array {
    if (!isset($_FILES[$name]) || (is_array($_FILES[$name]["name"]) && empty($_FILES[$name]["name"])) || empty($_FILES[$name]["name"])) {
      return $this->createError("Missing form-field '$name'");
    }

    $files = [];
    if (is_array($_FILES[$name]["name"])) {
      $numFiles = count($_FILES[$name]["name"]);
      if (!$allowMultiple && $numFiles > 1) {
        return $this->createError("Only one file allowed for form-field '$name'");
      } else {
        for ($i = 0; $i < $numFiles; $i++) {
          $fileName = $_FILES[$name]["name"][$i];
          $filePath = $_FILES[$name]["tmp_name"][$i];
          $files[$fileName] = $filePath;

          if (!empty($extensions) && !in_array(pathinfo($fileName, PATHINFO_EXTENSION), $extensions)) {
            return $this->createError("File '$fileName' has forbidden extension, allowed: " . implode(",", $extensions));
          }
        }
      }
    } else {
      $fileName = $_FILES[$name]["name"];
      $filePath = $_FILES[$name]["tmp_name"];
      $files[$fileName] = $filePath;
      if (!empty($extensions) && !in_array(pathinfo($fileName, PATHINFO_EXTENSION), $extensions)) {
        return $this->createError("File '$fileName' has forbidden extension, allowed: " . implode(",", $extensions));
      }
    }

    if ($allowMultiple) {
      return $files;
    } else {
      $fileName = key($files);
      return [$fileName, $files[$fileName]];
    }
  }

  public static function getApiEndpoints(): array {

    // first load all direct classes
    $classes = [];
    $apiDirs = ["Core", "Site"];
    foreach ($apiDirs as $apiDir) {
      $basePath = realpath(WEBROOT . "/$apiDir/API/");
      if (!$basePath) {
        continue;
      }

      foreach (scandir($basePath) as $fileName) {
        $fullPath = $basePath . "/" . $fileName;
        if (is_file($fullPath) && endsWith($fileName, ".class.php")) {
          require_once $fullPath;
          $apiName = explode(".", $fileName)[0];
          $className = "\\$apiDir\\API\\$apiName";
          if (!class_exists($className)) {
            continue;
          }

          $reflectionClass = new \ReflectionClass($className);
          if (!$reflectionClass->isSubclassOf(Request::class) || $reflectionClass->isAbstract()) {
            continue;
          }

          $endpoint = "$className::getEndpoint"();
          $classes[$endpoint] = $reflectionClass;
        }
      }
    }

    // then load all inheriting classes
    foreach (get_declared_classes() as $declaredClass) {
      $reflectionClass = new \ReflectionClass($declaredClass);
      if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) {
        $className = $reflectionClass->getName();
        $endpoint = "$className::getEndpoint"();
        $classes[$endpoint] = $reflectionClass;
      }
    }

    return $classes;
  }

  protected function logUserId(): string {
    $currentUser = $this->context->getUser();
    return $currentUser ? "userId='" . $currentUser->getId() . "'" : "SYSTEM";
  }

  protected function formatDuration(int $count, string $string): string {
    if ($count === 1) {
      return $string;
    } else {
      return "the next $count {$string}s";
    }
  }

  public static function loadDefaultACL(Insert $query): void {
    if (static::hasConfigurablePermissions()) {
      $method = static::getEndpoint();
      $groups = static::getDefaultPermittedGroups();
      $description = static::getDescription();
      $isCore = startsWith(get_class(), "Core\\API\\");
      $query->addRow($method, $groups, $description, $isCore);
    }
  }
}