/**
 *  Copyright © 2025, Luis Andrés Lange <https://javacomm.net>
 *
 *  Previously released under Apache License 2.0; now licensed under MPL 2.0.
 *
 *  This Source Code Form is subject to the terms of the Mozilla Public
 *  License, v. 2.0. If a copy of the MPL was not distributed with this
 *  file, You can obtain one at http://mozilla.org/MPL/2.0/.
 *
 *  ----------------------------------------------------------------------------
 *
 *  Exhibit B - "Incompatible With Secondary Licenses" Notice
 *
 *  This Source Code Form is "Incompatible With Secondary Licenses",
 *  as defined by the Mozilla Public License, v. 2.0.
 *
 *  In short:
 *  - This file may be used, modified, and distributed under MPL 2.0 only.
 *  - It may NOT be relicensed under GPL, LGPL, AGPL, or any other Secondary License.
 *
 *  Rationale:
 *  - Ensures that the code remains MPL-2.0.
 *  - Avoids legal conflicts with GPL-licensed libraries (e.g., VideoLAN).
 *  - Maximizes usability for commercial and security-critical applications.
 *
 */
package net.javacomm.restserver;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import java.security.PrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import net.javacomm.database.DatabaseException;
import net.javacomm.database.WebdatabaseImpl;
import net.javacomm.database.entity.EntityUser;
import net.javacomm.facade.DatabaseService;
import net.javacomm.multilingual.MultilingualString;
import net.javacomm.multilingual.schema.ISO639;
import net.javacomm.multilingual.schema.KEY;
import net.javacomm.protocol.CBlock;
import net.javacomm.protocol.Language;
import net.javacomm.protocol.SIGNIN;
import net.javacomm.protocol.Token;
import net.javacomm.protocol.UserOnline;
import net.javacomm.protocol.crypto.Crypto;
import net.javacomm.server.Mailsender;
import net.javacomm.transfer.Helado;
import net.javacomm.transfer.Sprachguete;
import net.javacomm.transfer.TransferTelefon;
import net.javacomm.transfer.TransferTooltip;
import net.javacomm.transfer.TransferUser;



@Produces(MediaType.TEXT_PLAIN)
@Path("/user")
public class User {

  private final static Logger log = LogManager.getLogger(User.class);
  private DatabaseService database;

  public User() {
    database = WebdatabaseImpl.getInstance();
  }



  /**
   * Handelt es sich um einen registrierten Anwender in der TB_ONLINE?
   * 
   * @param pseudoid
   *                       die PSEUDOID
   * @param pseudoPassword
   *                       das Passwort ist AES verschlüsselt
   * 
   * @return {@code true} der Anwender ist bekannt, sonst {@code HTTP 404}
   * 
   */
  @Path("/check/user")
  @GET
  public Response checkUser(@HeaderParam("pseudoid") String pseudoid,
      @HeaderParam("pseudoPassword") String pseudoPassword) {

    return isUserAuthenticated(pseudoid, pseudoPassword) ? Response.ok().build()
        : Response.status(Status.NOT_FOUND).entity("Anwender ist nicht anwesend").build();
  }



  @Path("/read/online")
  @Produces(MediaType.APPLICATION_JSON)
  @GET
  public List<UserOnline> findOnlineUsersByPattern(@QueryParam(value = "pattern") String pattern) {
    if (pattern == null) {
      String errormessage = "pattern is null";
      log.error(errormessage);
      Response response = Response.status(Status.BAD_REQUEST).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    ArrayList<UserOnline> onlineuser = database.findOnlineUsersByPattern(pattern);
    return onlineuser;
  }



  /**
   * Wenn der Timestamp geringer oder gleich 3 Sekunden lang ist, liegt er
   * innerhalb der Toleranz.
   * 
   * @param pseudoid
   *                       Pseudoid vom Client
   * @param pseudoPassword
   *                       Pseudopasswort vom Client
   * @param timestamp
   *                       Timestamp vom Client
   * 
   * @return {@code true}, die Toleranz wurde eingehalten
   */
  boolean isTimestampValid(long timestamp) {

    // 1. Hol dir die aktuelle, echte Serverzeit
    long serverTs = System.currentTimeMillis();

    // 2. Hol dir den Zeitstempel aus dem CBlock (der vom Client "geicht" wurde)
    long clientTs = timestamp;

    // 3. Die Differenz-Berechnung (Der absolute Betrag)
    long difference = Math.abs(serverTs - clientTs);

    // 4. Der Schwellenwert-Vergleich
    return difference <= 3000;

  }



  /**
   * Ist der Anwender eingeloggt?
   * 
   * @param pseudoid
   *                       diese Pseudoid
   * @param pseudoPassword
   *                       dieses Pseudopasswort
   * @return {@code true} eingeloggt, sonst {@code false}
   */
  public boolean isUserAuthenticated(String pseudoid, String pseudoPassword) {
    Optional<String> uid = database.fetchUserByPseudoid(pseudoid);
    if (uid.isEmpty()) return false;
    String userid = uid.get();
    return database.checkPassword(userid, pseudoid, pseudoPassword);
  }



  /**
   * Alle Attribute für einen Anwender aus der Tabelle TB_USER werden gelesen. Der
   * Rückgabewert kann leer sein, wenn kein Anwender gefunden wurde.
   *
   * @param pseudoid
   *                 ein Anwender
   * @return die Attribute sind in genau einem Listenelement
   */
  @Path("/read/data")
  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public List<TransferUser> readDataHeader(@HeaderParam(value = "pseudoid") String pseudoid,
      @HeaderParam(value = "cblock") String cblock) {

    CBlock block = CBlock.toCBlock(cblock);
    if (block == null) {
      String errormessage = "cblock is null";
      log.error(errormessage);
      Response response = Response.status(Status.BAD_REQUEST).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    if (pseudoid == null) {
      String errormessage = "pseudoid is null";
      log.error(errormessage);
      Response response = Response.status(Status.BAD_REQUEST).entity(errormessage).build();
      throw new WebApplicationException(response);
    }

    if (!isUserAuthenticated(pseudoid, block.getPseudoPassword())) {
      String errormessage = "Der Anwender ist nicht bekannt.";
      log.error(errormessage);
      Response response = Response.status(Status.NOT_FOUND).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    if (!isTimestampValid(block.getTimestamp())) {
      String errormessage = "Möglicher Replay-Angriff. Die Karenzzeit wurde nicht eingehalten.";
      log.error(errormessage);
      Response response = Response.status(Status.FORBIDDEN).entity(errormessage).build();
      throw new WebApplicationException(response);
    }

    String userid = database.fetchUserByPseudoid(pseudoid).get();
    net.javacomm.database.entity.EntityUser entityUser = database.fetchUser(userid);

    ArrayList<TransferUser> list = new ArrayList<>();
    TransferUser transferuser = new TransferUser();

    transferuser.setUserid(userid);
    transferuser.setBenutzerstatus(entityUser.getBenutzerstatus());
    transferuser.setMail(entityUser.getMail());
    transferuser.setChatdenied(entityUser.isChatdenied());
    transferuser.setChattooltip(entityUser.isChattooltip());
    transferuser.setVolume(entityUser.getVolume());
    transferuser.setPlayervolume(entityUser.getPlayervolume());
    transferuser.setOncall(entityUser.isOncall());
    transferuser.setTelefonpuffer(Sprachguete.toSprachguete(entityUser.getTelefonpuffer()));
    transferuser.setHelado(Helado.toHelado(entityUser.getEis()));
    transferuser.setSpeaker(entityUser.getSpeaker());
    transferuser.setMikrofon(entityUser.getMikrofon());
    transferuser.setOnrekord(entityUser.isOnrekord());
    transferuser.setOnprojektor(entityUser.isOnprojektor());
    transferuser.setIptvvolume(entityUser.getIptvvolume());
    transferuser.setLanguage(entityUser.getLanguage());
    transferuser.setChatdownloaddir(entityUser.getChatDownloadDir());
    transferuser.setChatuploaddir(entityUser.getChatUploadDir());
    transferuser.setLdelete(entityUser.isLdelete());
    transferuser.setNickname(entityUser.getNickname());
    list.add(transferuser);
    return list;
  }



  /**
   * Das Einmal-Token wird bei einem USRLOGIN und UPDATE benötigt. Es ist Teil vom
   * IDENTITY Attribut.
   * 
   * @return dieses Einmal-Token
   */
  @Path("/read/token")
  @POST
  @Produces(MediaType.TEXT_PLAIN)
  public String readToken() {
    String uuid = UUID.randomUUID().toString();
    database.insertToken(uuid);
    return uuid;
  }



  /**
   * Der Anwender fordert ein neues Password an.
   * 
   * @param identity
   *                 die Benutzerdaten sind RSA verschlüsselt und wurden Base64
   *                 codiert übergeben
   * 
   * @return
   */
  @PUT
  @Path("/renew/password/{language}")
  @Produces(MediaType.TEXT_PLAIN)
  public Response renewPassword(@PathParam("language") String language, String identity) {
    try {
      String rsa = database.fetchRsaPrivateKey();
      PrivateKey privateKey = Crypto.loadPrivateRSAKey(rsa);
      String klartext = Crypto.decryptRSA(identity, privateKey);
      Token token = Token.toToken(klartext);

      // Ist die Email vorhanden

      EntityUser entityUser = database.fetchUserByMail(token.getEMail());

      if (entityUser == null) {
        // Anwender ist nicht registriert
        // 404 NOT_FOUND

        MultilingualString text = new MultilingualString(
            KEY.STRING_KEIN_REGISTRIERTER_ANWENDER, ISO639.fromValue(language)
        );

        Response response = Response.status(Status.NOT_FOUND).type(MediaType.TEXT_PLAIN)
            .entity(text.toString()).build();
        return response;
      }

      // Anwender ist registriert

//      log.info("CONFIRMATION_FLAG=" + entityUser.getConfirmation_flag());
      if (entityUser.getConfirmation_flag() == 1) {
        // 403 FORBIDDEN
        // vorübergehend gesperrt

        MultilingualString text = new MultilingualString(
            KEY.STRING_KONTO_GESPERRT, ISO639.fromValue(language)
        );

        Response response = Response.status(Status.FORBIDDEN).type(MediaType.TEXT_PLAIN)
            .entity(text.toString()).build();
        return response;
      }

      // Password darf erneuert werden

      // Passwort an die Mailadresse senden

      // neues Neues_PASSWORT und CONFIRMATION_KEY werden in der Datenbank gesetzt.
      SIGNIN signin = database.createConfirmationKey(entityUser.getUid());

      // kann einen SocketTimeout verursachen
      // die Zeit wurde in JerseyClientFactory auf 9 Sekunden gesetzt

      new Thread(() -> {
        Mailsender.sendMail(
            entityUser.getMail(), signin.getNeuesPassword(), entityUser.getLanguage().toString(),
            signin.getConfirmationKey()
        );
      }).start();

      database.updateConfirmationFlag(entityUser.getUid(), entityUser.getMail(), 1);

      MultilingualString text = new MultilingualString(KEY.SERVER_NEUES_PASSWORT, ISO639.fromValue(language));

      return Response.ok().entity(text.toString()).build();
    }
    catch (Exception e) {
      // 400, Identitätsstring ist fehlerhaft
      MultilingualString text = new MultilingualString(
          KEY.STRING_NOT_ENCRYPTING_PROPERLY, ISO639.fromValue(language)
      );
      Response response = Response.status(Status.BAD_REQUEST).type(MediaType.TEXT_PLAIN)
          .entity(e.getMessage()).entity(text.toString()).build();
      throw new WebApplicationException(response);
    }
  }



  /**
   * Sollen Chatanfragen abgelehnt werden?
   *
   *
   * @param userid
   *                   die Benutzer-ID vom Schreiber
   * @param chatdenied
   *                   {@code true}, Chatanfragen werden abgelehnt
   */
  @Path("/write/chatdenied")
  @POST
  public void writeChatdenied(@FormParam(value = "pseudoid") String pseudoid,
      @FormParam(value = "chatdenied") boolean chatdenied) {

    String userid = database.fetchUserByPseudoid(pseudoid).get();
    if (userid == null) {
      String errormessage = "Der Anwender ist nicht bekannt.";
      log.error(errormessage);
      Response response = Response.status(Status.NOT_FOUND).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    database.updateChatdenied(userid, chatdenied);
  }



  @Path("/write/chattooltip")
  @PUT
  @Consumes(MediaType.APPLICATION_JSON)
  public Response writeChattooltip(TransferTooltip transfer) {

    if (!isUserAuthenticated(transfer.getPseudoid(), transfer.getCblock().getPseudoPassword())) {
      return Response.status(Status.NOT_FOUND).build();
    }

    if (!isTimestampValid(transfer.getCblock().getTimestamp())) {
      return Response.status(Status.FORBIDDEN).build();
    }

    Optional<String> userid = database.fetchUserByPseudoid(transfer.getPseudoid());
    if (userid.isEmpty()) {
      // bei einem Verbindungsverlust kann die pseudoid null sein
      String errormessage = transfer.getPseudoid() + " - Der Anwender hat seine Websocketverbindung verloren";
      log.warn(errormessage);
      Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
      throw new WebApplicationException(response);
    }

    database.updateChattooltip(transfer.isTooltip(), userid.get());
    return Response.noContent().build();
  }



  /**
   * Globale Benutzerattribute werden gespeichert.
   *
   *
   */
  @Path("/write/global/attributes")
  @POST
  public Response writeGlobalAttributes(@FormParam(value = "pseudoid") String pseudoid,
      @FormParam(value = "cblock") String cblock, @FormParam(value = "playervolume") int playervolume,
      @FormParam(value = "iptvvolume") int iptvvolume, @FormParam(value = "eis") Helado helado,
      @FormParam(value = "language") String language,
      @FormParam(value = "chatDownloadDir") String chatDownloadDir,
      @FormParam(value = "chatUploadDir") String chatUploadDir) {

    CBlock toCblock = CBlock.toCBlock(cblock);

    if (!isUserAuthenticated(pseudoid, toCblock.getPseudoPassword())) {
      return Response.status(Status.NOT_FOUND).build();
    }

    if (!isTimestampValid(toCblock.getTimestamp())) {
      return Response.status(Status.FORBIDDEN).build();
    }

    Optional<String> userid = database.fetchUserByPseudoid(pseudoid);
    if (userid.isEmpty()) {
      // bei einem Verbindungsverlust kann die pseudoid null sein
      String errormessage = pseudoid + " - Der Anwender hat seine Websocketverbindung verloren";
      log.warn(errormessage);
      Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    database.updateGlobalAttributes(
        userid.get(), playervolume, iptvvolume, helado.getHelado(), language, chatDownloadDir, chatUploadDir
    );

    return Response.ok().build();

  }



  /**
   * Der Anwender legt seine Spracheinstellung fest.
   * 
   * @param language
   *                 diese Sprache
   * @param pseudoid
   *                 dieser Anwender
   * @return {@code true}, wenn die Spracheinstellung übernommen wurde
   */
  @PUT
  @Path("/write/language/{language}")
  @Produces(MediaType.TEXT_PLAIN)
  public boolean writeLanguage(@PathParam(value = "language") Language language, String pseudoid) {
    try {
      Optional<String> userid = database.fetchUserByPseudoid(pseudoid);
      if (userid.isEmpty()) {
        String errormessage = pseudoid + " - Der Anwender hat seine Websocketverbindung verloren";
        log.warn(errormessage);
        Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
        throw new WebApplicationException(response);
      }
      return database.updateLanguage(language, userid.get());
    }
    catch (DatabaseException e) {
      log.error(e.getMessage());
      throw new WebApplicationException();
    }
  }



  /**
   * Der Anwender teilt mit, dass sein Projektor läuft.
   *
   * @param projektor
   * @return {@code true}, der Projektor ist in Betrieb
   */
  @Path("/write/onprojektor")
  @POST
  public boolean writeOnprojektor(@FormParam(value = "pseudoid") String pseudoid,
      @FormParam(value = "projektor") boolean projektor) {
    EntityUser user = new EntityUser();
    // Was ist, wenn der User offline ist? nichts
    try {
      Optional<String> userid = database.fetchUserByPseudoid(pseudoid);
      if (userid.isEmpty()) {
        String errormessage = pseudoid + " - Der Anwender hat seine Websocketverbindung verloren";
        log.warn(errormessage);
        Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
        throw new WebApplicationException(response);
      }
      user.setUid(userid.get());
      user.setOnprojektor(projektor);
      return database.updateOnprojektor(user);
    }
    catch (DatabaseException e) {
      log.error(e.getMessage());
      throw new WebApplicationException();
    }
  }



  /**
   * Der Anwender teilt seine Einwilligung oder Ablehnung für den
   * Bildschirmempfang mit.
   *
   * @param rekorder
   * @return {@code true}, die Daten konnten gespeichert werden.
   */
  @Path("/write/rekorder")
  @POST
  public boolean writeRekorder(@FormParam(value = "pseudoid") String pseudoid,
      @FormParam(value = "rekorder") boolean rekorder) {

    EntityUser user = new EntityUser();
    Optional<String> userid = database.fetchUserByPseudoid(pseudoid);
    if (userid.isEmpty()) {
      // bei einem Verbindungsverlust kann die pseudoid null sein
      String errormessage = pseudoid + " - Der Anwender hat seine Websocketverbindung verloren";
      log.warn(errormessage);
      Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    user.setUid(userid.get());
    user.setOnrekord(rekorder);
    boolean done = database.updateRekorder(user);
    return done;
  }



  /**
   * Nur die Benutzerdaten für das Telefon werden gespeichert.
   *
   *
   * @param telefon
   *                die Telefondaten wie Telefonport, Sprachgüte und Userid
   * @return {@code true}, die Daten konnten gespeichert werden.
   */
  @Path("/write/telefon")
  @POST
  @Produces(MediaType.TEXT_PLAIN)
  public boolean writeTelefon(@FormParam(value = "pseudoid") String pseudoid,
      @FormParam(value = "telefon") TransferTelefon telefon) {

    net.javacomm.database.entity.EntityUser user = new net.javacomm.database.entity.EntityUser();
    Optional<String> userid = database.fetchUserByPseudoid(pseudoid);
    if (userid.isEmpty()) {
      // bei einem Verbindungsverlust kann die pseudoid null sein
      String errormessage = pseudoid + " - Der Anwender hat seine Websocketverbindung verloren";
      log.warn(errormessage);
      Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    String uid = userid.get();
    user.setUid(uid);
    user.setTelefonpuffer(telefon.getVoiceQuality().getPuffer());
    user.setMikrofon(telefon.getTelefonMikrofon());
    user.setSpeaker(telefon.getTelefonLautsprecher());
    boolean done = database.updateTelefon(user);
    return done;
  }



  @Path("/volume")
  @POST
  public void writeTelefonvolume(@FormParam(value = "pseudoid") String pseudoid,
      @FormParam(value = "volume") int volume) {

    Optional<String> userid = database.fetchUserByPseudoid(pseudoid);
    if (userid.isEmpty()) {
      // bei einem Verbindungsverlust kann die pseudoid null sein
      String errormessage = pseudoid + " - Der Anwender hat seine Websocketverbindung verloren";
      log.warn(errormessage);
      Response response = Response.status(Status.PRECONDITION_REQUIRED).entity(errormessage).build();
      throw new WebApplicationException(response);
    }
    String uid = userid.get();
    database.updatePhoneVolume(uid, volume);
  }

//  Die Drift-Logik im Detail
//
//  Wenn der Server t1​ sendet und der Client t2​ misst, ist die Differenz dein Korrekturwert.
//
//      Berechnung: drift=t1​−t2​
//
//      Anwendung beim Senden: Sendezeit=System.currentTimeMillis()+drift
//
//  Damit "eichst" du den Client auf die Serverzeit. Wenn der Server 14:00:05 Uhr hat und der Client 14:00:00 Uhr, ist der drift=+5 Sekunden. Der Client addiert diese 5 Sekunden bei jedem Request auf seine eigene Zeit auf, und beim Server kommt exakt die Zeit an, die er erwartet.

}
