<?php

namespace Khofmann\Models\User;

use Exception;
use PDO;
use DateTime;
use Khofmann\Database\Database;
use Khofmann\Config\Config;
use JsonSerializable;
use Khofmann\ApiError\ApiError;
use Khofmann\GUID\GUID;
use Khofmann\Models\Post\Post;

/**
 * User database model
 *
 * Abstracts database access
 */
class User implements JsonSerializable
{
  private int $id;
  private string $username;
  private int $status;
  private string $email;
  private ?string $image;
  private bool $isAdmin;
  private DateTime $memberSince;
  private int $postCount;

  private function __construct(int $id, string $username, int $status, string $email, string $timestamp, ?string $image, bool $isAdmin, int $postCount)
  {
    $this->id = $id;
    $this->username = $username;
    $this->status = $status;
    $this->email = $email;
    $this->image = $image;
    $this->isAdmin = $isAdmin;
    $this->memberSince = new DateTime($timestamp);
    $this->postCount = $postCount;
  }

  /**
   * Get a user by their confirmation code.
   *
   * @param string $code Confirmation code
   *
   * @throws NotFound User not found
   */
  private static function getByConfirmCode(string $code): User
  {
    $db = Database::getInstance();

    $stmt = $db->prepare(
      "SELECT
        b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
       FROM
        egb_benutzer AS b
      WHERE
        confirmationcode	 = :COD"
    );
    $stmt->bindValue(":COD", $code);
    $stmt->execute();
    $data = $stmt->fetch();

    if (!$data) throw new Exception("NotFound");

    return new User($data["id"], $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
  }

  /*
  * Statics
  */

  /**
   * Get a user by ID.
   *
   * @param int $id User ID
   *
   * @throws NotFound User not found
   */
  public static function getByID(int $id): User
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "SELECT
        b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
      FROM
        egb_benutzer AS b
      WHERE
        b.id = :ID"
    );
    $stmt->bindValue(":ID", $id);
    $stmt->execute();
    $data = $stmt->fetch();

    if (!$data) throw new Exception("NotFound");

    return new User($id, $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
  }

  /**
   * Get a user by their email.
   *
   * @param string $email User email
   *
   * @throws NotFound User not found
   */
  public static function getByEmail(string $email): User
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "SELECT
        b.id, b.benutzer, b.status, b.image, b.isadmin, b.zeitstempel,
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
      FROM
        egb_benutzer AS b
      WHERE
        email = :EMAIL"
    );
    $stmt->bindValue(":EMAIL", $email);
    $stmt->execute();
    $data = $stmt->fetch();

    if (!$data) throw new Exception("NotFound");

    return new User($data["id"], $data["benutzer"], $data["status"], $email, $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
  }

  /**
   * Get a user by their authentication token.
   *
   * @param string $token Authentication token
   *
   * @throws NotFound User not found
   */
  public static function getByToken(string $token): User
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "SELECT 
        b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
       FROM
        egb_benutzer AS b
      WHERE
        token = :TOKEN AND
        tokenExpiry > NOW()"
    );
    $stmt->bindValue(":TOKEN", $token);
    $stmt->execute();
    $data = $stmt->fetch();

    if (!$data) throw new Exception("NotFound");

    return new User($data["id"], $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
  }

  /**
   * Log in a user
   *
   * @param string $email User email
   * @param string $password USer password
   *
   * @return array Tokens and user
   *
   * @throws Failed Could not generate tokens
   * @throws Invalid Password was wrong
   * @throws NotFound User not found (email wrong)
   */
  public static function logIn(string $email, string $password): array
  {
    $db = Database::getInstance();
    // Get user data
    $stmt = $db->prepare(
      "SELECT
        b.*, 
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
      FROM
        egb_benutzer AS b
      WHERE
        email LIKE :EMAIL AND
        status = 1"
    );
    $stmt->bindValue(":EMAIL", $email);
    $stmt->execute();
    $data = $stmt->fetch();

    if ($data) {
      $user = new User($data["id"], $data["benutzer"], $data["status"], $email, $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
      if (password_verify($password, $data["passwort"])) {
        // REHASH for safety should it somehow change
        if (password_needs_rehash($data["passwort"], PASSWORD_DEFAULT)) {
          $newHash = password_hash($password, PASSWORD_DEFAULT);
          $stmt = $db->prepare("UPDATE egb_benutzer SET passwort = :PAS WHERE id = :ID");
          $stmt->bindValue(":PAS", $newHash);
          $stmt->bindValue(":ID", $user->getID());
          $stmt->execute();
        }
        // Generate tokens only if expired or missing
        if (empty($data["token"]) || new DateTime($data["tokenExpiry"]) <= new DateTime()) {
          $stmt = $db->prepare(
            "UPDATE
              egb_benutzer
            SET 
              token = UUID(),
              tokenExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getTokenExpiry() . "),
              refreshToken = UUID(),
              refreshExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getRefreshTokenExpiry() . ")
            WHERE id = :ID"
          );
          $stmt->bindValue(":ID", $user->getID());
          $stmt->execute();
        }
        // Get token
        $stmt = $db->prepare("SELECT token, refreshToken FROM egb_benutzer WHERE id = :ID");
        $stmt->bindValue(":ID", $user->getID());
        $stmt->execute();
        [$token, $refresh] = $stmt->fetch(PDO::FETCH_NUM);
        // Return user and tokens
        if ($token) {
          return ["user" => $user, "token" => $token, "refreshToken" => $refresh];
        }
        // Token generation failed
        throw new Exception("Failed");
      } else {
        // PW wrong
        throw new Exception("Invalid");
      }
    } else {
      // User does not exist
      throw new Exception("NotFound");
    }
  }

  /**
   * Create a user
   *
   * @param string $username Username
   * @param string $email User email
   * @param string $password User password
   *
   * @throws Duplicate User with this username or email already exists
   */
  public static function create(string $username, string $email, string $password): User
  {
    $db = Database::getInstance();
    $guid = GUID::v4();

    $stmt = $db->prepare(
      "INSERT INTO
        egb_benutzer(benutzer, passwort, email, confirmationcode)
      VALUES(:USR, :PAS, :EMA, :COD)"
    );
    $stmt->bindValue(":USR", htmlspecialchars($username));
    $stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT));
    $stmt->bindValue(":EMA", $email);
    $stmt->bindValue(":COD", $guid);

    try {
      $stmt->execute();

      $user = User::getByID($db->lastInsertId());

      mail(
        $email,
        "Account activation GuestBookDB",
        "Hello $username. To activate your account, visit https://khofmann.userpage.fu-berlin.de/phpCourse/exam/confirm?code=$guid"
      );

      return $user;
    } catch (Exception $err) {
      if ($err->getCode() === "23000") throw new Exception("Duplicate");

      throw $err;
    }
  }

  /**
   * Confirm a user
   *
   * @param string $confirmCode Confirmation code
   *
   * @throws NotFound User not found (invalid code or already confirmed)
   */
  public static function confirm(string $confirmCode): User
  {
    $db = Database::getInstance();
    $user = User::getByConfirmCode($confirmCode);

    $stmt = $db->prepare(
      "UPDATE
          egb_benutzer
        SET
          status = 1,
          confirmationcode = NULL
        WHERE id = :UID"
    );
    $stmt->bindValue(":UID", $user->getID());
    $stmt->execute();
    return User::getByID($user->getID());
  }

  /**
   * List of users
   *
   * @param int $page Current page (offset)
   * @param int $limit Users per page
   *
   * @return array Number of pages and posts of selected page
   */
  public static function list(int $page, int $limit)
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "SELECT
        COUNT(*)
      FROM
        egb_gaestebuch"
    );
    $stmt->execute();
    $count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
    $stmt = $db->prepare(
      "SELECT
        b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
      FROM
        egb_benutzer AS b
      LIMIT $limit
      OFFSET " . ($page * $limit)
    );
    $stmt->execute();
    $data = $stmt->fetchAll();

    $list = array_map(
      fn ($item) => new User($item["id"], $item["benutzer"], $item["status"], $item["email"], $item["zeitstempel"], $item["image"], $item["isadmin"] === 1, $item["postCount"]),
      $data
    );

    return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $list];
  }

  /**
   * Refresh a user session
   *
   * @param string $token Authentication token
   * @param string $refreshToken Refresh token
   *
   * @throws NotFound User not found (tokens do not exist, refresh token expired)
   * @throws Failed Could not regenerate tokens
   */
  public static function refresh(string $token, string $refreshToken)
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "SELECT 
        b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel, b.tokenExpiry,
        (SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
       FROM
        egb_benutzer AS b
      WHERE
        token = :TOKEN AND
        refreshToken = :REFRESH AND
        refreshExpiry > NOW()"
    );
    $stmt->bindValue(":TOKEN", $token);
    $stmt->bindValue(":REFRESH", $refreshToken);
    $stmt->execute();
    $data = $stmt->fetch();

    if (!$data) throw new Exception("NotFound");

    $user = new User($data["id"], $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);

    // Update tokens if expired
    if (new DateTime($data["tokenExpiry"]) <= new DateTime()) {
      $stmt = $db->prepare(
        "UPDATE
        egb_benutzer
      SET 
        token = UUID(),
        tokenExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getTokenExpiry() . "),
        refreshToken = UUID(),
        refreshExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getRefreshTokenExpiry() . ")
      WHERE id = :ID"
      );
      $stmt->bindValue(":ID", $user->getID());
      $stmt->execute();
    }

    // Get token
    $stmt = $db->prepare("SELECT token, refreshToken FROM egb_benutzer WHERE id = :ID");
    $stmt->bindValue(":ID", $user->getID());
    $stmt->execute();
    [$token, $refresh] = $stmt->fetch(PDO::FETCH_NUM);
    // Return user and tokens
    if ($token) {
      return ["user" => $user, "token" => $token, "refreshToken" => $refresh];
    }
    // Token generation failed
    throw new Exception("Failed");
  }

  /*
  * Members
  */

  public function logOut(): bool
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "UPDATE
        egb_benutzer
      SET
        token = NULL,
        tokenExpiry = NULL,
        refreshToken = NULL,
        refreshExpiry = NULL
      WHERE id = :ID"
    );
    $stmt->bindValue(":ID", $this->id);
    return $stmt->execute();
  }

  /**
   * Update user
   *
   * Does nothing if new all fields are empty
   *
   * @param ?string $username New username
   * @param ?string $password New password
   * @param ?string $email New email
   *
   * @throws Failed At least one field failed to update
   */
  public function update(?string $username, ?string $password, ?string $email): User
  {
    $db = Database::getInstance();

    // Make sure we do all changes or none
    $db->beginTransaction();

    $failed = [];
    $reasons = [];
    if (!empty($username)) {
      $stmt = $db->prepare("UPDATE egb_benutzer SET benutzer = :USR WHERE id = :ID");
      $stmt->bindValue(":USR", htmlspecialchars($username));
      $stmt->bindValue(":ID", $this->id);
      try {
        if (!$stmt->execute()) {
          array_push($failed, "username");
          array_push($reasons, "generic");
        }
      } catch (Exception $e) {
        array_push($failed, "username");
        if ($e->getCode() === "23000") {
          $pdoErr = $stmt->errorInfo()[1];
          if ($pdoErr === 1062) array_push($reasons, "Duplicate");
          else array_push($reasons, "SQL: $pdoErr");
        } else array_push($reasons, "{$e->getCode()}");
      }
    }

    if (!empty($password)) {
      $stmt = $db->prepare("UPDATE egb_benutzer SET passwort = :PAS WHERE id = :ID");
      $stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT));
      $stmt->bindValue(":ID", $this->id);
      try {
        if (!$stmt->execute()) {
          array_push($failed, "password");
          array_push($reasons, "generic");
        }
      } catch (Exception $e) {
        array_push($failed, "password");
        if ($e->getCode() === "23000") {
          $pdoErr = $stmt->errorInfo()[1];
          if ($pdoErr === 1062) array_push($reasons, "Duplicate");
          else array_push($reasons, "SQL: $pdoErr");
        } else array_push($reasons, "{$e->getCode()}");
      }
    }

    if (!empty($email)) {
      $stmt = $db->prepare("UPDATE egb_benutzer SET email = :EMA WHERE id = :ID");
      $stmt->bindValue(":EMA", $email);
      $stmt->bindValue(":ID", $this->id);
      try {
        if (!$stmt->execute()) {
          array_push($failed, "email");
          array_push($reasons, "generic");
        }
      } catch (Exception $e) {
        array_push($failed, "email");
        if ($e->getCode() === "23000") {
          $pdoErr = $stmt->errorInfo()[1];
          if ($pdoErr === 1062) array_push($reasons, "Duplicate");
          else array_push($reasons, "SQL: $pdoErr");
        } else array_push($reasons, "{$e->getCode()}");
      }
    }

    if (count($failed) > 0) {
      // We failed, go back
      $db->rollBack();

      throw ApiError::failedUpdate($failed, $reasons);
    }

    // Commit the changes
    $db->commit();

    return User::getByID($this->id);
  }

  /**
   * Update user
   *
   * Does nothing if all fields are empty
   *
   * @param mixed $image New file upload
   * @param ?string $predefined Predefined avatar
   * 
   * @param Failed Image failed to update
   */
  public function updateImage($image, ?string $predefined): User
  {
    $db = Database::getInstance();

    // Grab old picture
    $stmt = $db->prepare("SELECT image FROM egb_benutzer WHERE id = :ID");
    $stmt->bindValue(":ID", $this->id);
    $stmt->execute();
    $oldImage = $stmt->fetch(PDO::FETCH_COLUMN, 0);

    // Make sure we do all changes or none
    $db->beginTransaction();

    $failed = [];
    $reasons = [];
    if (!empty($image)) {
      // Generate new filename
      $destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
      // Try and update user record
      try {
        $stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
        $stmt->bindValue(":IMG", "profilbilder/$destinationFilename");
        $stmt->bindValue(":ID", $this->id);

        if (!$stmt->execute()) {
          array_push($failed, "image");
          array_push($reasons, "generic");
        }
        // Move file
        $image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
      } catch (Exception $e) {
        array_push($failed, "image");
        if ($e->getCode() === "23000") {
          $pdoErr = $stmt->errorInfo()[1];
          if ($pdoErr === 1062) array_push($reasons, "Duplicate");
          else array_push($reasons, "SQL: $pdoErr");
        } else array_push($reasons, "{$e->getCode()}");
      }
    } else if (!empty($predefined)) {
      $stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
      $stmt->bindValue(":IMG", "profilbilder/default/$predefined.svg");
      $stmt->bindValue(":ID", $this->id);
      try {
        if (!$stmt->execute()) {
          array_push($failed, "image");
          array_push($reasons, "generic");
        }
      } catch (Exception $e) {
        array_push($failed, "image");
        if ($e->getCode() === "23000") {
          $pdoErr = $stmt->errorInfo()[1];
          if ($pdoErr === 1062) array_push($reasons, "Duplicate");
          else array_push($reasons, "SQL: $pdoErr");
        } else array_push($reasons, "{$e->getCode()}");
      }
    }

    if (count($failed) > 0) {
      // We failed, go back
      $db->rollBack();

      throw ApiError::failedUpdate($failed, $reasons);
    }

    // Commit the changes
    $db->commit();

    // Delete old picture if it isn't a default one and if it exists.
    try {
      if (strpos($oldImage, "default") === false && is_file(Config::getStorageFSPath() . $oldImage)) unlink(Config::getStorageFSPath() . $oldImage);
    } catch (Exception $e) {
    }

    return User::getByID($this->id);
  }

  /**
   * Update user
   *
   * Does nothing if new all fields are empty
   *
   * @param ?bool $isAdmin Admin permission
   *
   * @throws Failed Failed to update admin status
   */
  public function updatePermissions(?bool $isAdmin): User
  {
    $db = Database::getInstance();

    // Make sure we do all changes or none
    $db->beginTransaction();

    $failed = [];
    $reasons = [];
    if (isset($isAdmin)) {
      $stmt = $db->prepare("UPDATE egb_benutzer SET isadmin = :ADM WHERE id = :ID");
      $stmt->bindValue(":ADM", $isAdmin);
      $stmt->bindValue(":ID", $this->id);
      try {
        if (!$stmt->execute()) {
          array_push($failed, "username");
          array_push($reasons, "generic");
        }
      } catch (Exception $e) {
        array_push($failed, "username");
        if ($e->getCode() === "23000") {
          $pdoErr = $stmt->errorInfo()[1];
          if ($pdoErr === 1062) array_push($reasons, "Duplicate");
          else array_push($reasons, "SQL: $pdoErr");
        } else array_push($reasons, "{$e->getCode()}");
      }
    }

    if (count($failed) > 0) {
      // We failed, go back
      $db->rollBack();

      throw ApiError::failedUpdate($failed, $reasons);
    }

    // Commit the changes
    $db->commit();

    return User::getByID($this->id);
  }

  /**
   * Delete user and image
   *
   * @param int $limit Limit of list for which the returned pages is calculated.
   *
   * @return array Returns deleted user and resulting amount of pages for a given limit.
   */
  public function delete(int $limit): array
  {
    $db = Database::getInstance();
    $stmt = $db->prepare("DELETE FROM egb_benutzer WHERE id = :ID");
    $stmt->bindValue(":ID", $this->id);
    $stmt->execute();

    $stmt = $db->prepare(
      "SELECT
        COUNT(*)
      FROM
        egb_benutzer"
    );
    $stmt->execute();
    $count = $stmt->fetch(PDO::FETCH_COLUMN, 0);

    // Delete picture if it isn't a default one and if it exists.
    try {
      if (strpos($this->image, "default") === false && is_file(Config::getStorageFSPath() . $this->image)) unlink(Config::getStorageFSPath() . $this->image);
    } catch (Exception $e) {
    }

    return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $this];
  }

  /**
   * List of posts by user
   *
   * @param int $page Current page (offset)
   * @param int $limit Users per page
   * @param string $sort Sort direction
   *
   * @return array Number of pages and posts of selected page
   */
  public function posts(int $page, int $limit, string $sort)
  {
    $db = Database::getInstance();
    $stmt = $db->prepare(
      "SELECT
        COUNT(*)
      FROM
        egb_gaestebuch
      WHERE
        benutzer_id = :ID"
    );
    $stmt->bindValue(":ID", $this->id);
    $stmt->execute();
    $count = $stmt->fetch(PDO::FETCH_COLUMN, 0);


    $stmt = $db->prepare(
      "SELECT
        *
      FROM
        egb_gaestebuch
      WHERE
        benutzer_id = :ID
      ORDER BY
        id $sort
      LIMIT $limit
      OFFSET " . ($page * $limit)
    );
    $stmt->bindValue(":ID", $this->id);
    $stmt->execute();
    $data = $stmt->fetchAll();

    $list = array_map(
      function ($item) {
        return new Post($item["id"], $this, null, null, $item["beitrag"], $item["zeitstempel"]);
      },
      $data
    );

    return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $list];
  }

  /**
   * Refresh user token expiry
   */
  public function keepFresh()
  {
    try {
      $db = Database::getInstance();
      $stmt = $db->prepare(
        "UPDATE
      egb_benutzer
    SET 
      tokenExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getTokenExpiry() . "),
      refreshExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getRefreshTokenExpiry() . ")
    WHERE id = :ID"
      );
      $stmt->bindValue(":ID", $this->getID());
      $stmt->execute();
    } catch (Exception $err) {
    }
  }

  /*
  * Getters
  */

  /**
   * Get user ID
   */
  public function getID(): int
  {
    return $this->id;
  }

  /**
   * Get username
   */
  public function getUsername(): string
  {
    return $this->username;
  }

  /**
   * Get user status
   *
   * * 0: Not confirmed
   * * 1: Confirmed
   */
  public function getStatus(): int
  {
    return $this->status;
  }

  /**
   * Get user email
   */
  public function getEmail(): string
  {
    return $this->email;
  }

  /**
   * Get user image path
   */
  public function getImage(): ?string
  {
    return $this->image;
  }

  /**
   * Get user admin status
   */
  public function getIsAdmin(): bool
  {
    return $this->isAdmin;
  }

  /**
   * Get time of user creation
   */
  public function getMemberSince(): DateTime
  {
    return $this->memberSince;
  }

  /**
   * Get count of posts by user
   */
  public function getPostCount(): int
  {
    return $this->postCount;
  }

  /*
  * JSON
  */

  public function jsonSerialize(): array
  {
    return [
      'id' => $this->id,
      'username' => $this->username,
      'status' => $this->status,
      'email' => htmlspecialchars($this->email),
      'image' => Config::getStoragePath() . $this->image,
      'isAdmin' => $this->isAdmin,
      'memberSince' => $this->memberSince,
      'postCount' => $this->postCount,
    ];
  }
}
