/**
 *  Copyright © 2020-2025, Luis Andrés Lange <https://javacomm.net>
 *
 *  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.client.filetransfer;

import static net.javacomm.protocol.HEADER.ERROR;
import static net.javacomm.protocol.HEADER.REQUEST;
import static net.javacomm.protocol.HEADER.RESPONSE;
import jakarta.websocket.DecodeException;
import jakarta.websocket.EncodeException;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.net.BindException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import net.javacomm.protocol.Command;
import net.javacomm.protocol.FTSDOWNLOAD;
import net.javacomm.protocol.MESSAGE;
import net.javacomm.protocol.MessageDecoder;
import net.javacomm.protocol.MessageEncoder;
import net.javacomm.protocol.Protocol;
import net.javacomm.protocol.TRANSFER;



/**
 * Es können mehrere FTServer auf unterschiedlichen Ports erzeugt werden.
 * 
 * In der Regel sollte jedoch nur einer erzeugt werden.
 * 
 * Gestartet wird der Server von außen über einen ExecutorService.
 * 
 * 
 * <pre>
 * 
 *    FTServer server = new FTServer(29467);
 *    executorFileTransferServer = Executors.newSingleThreadExecutor();
 *    executorFileTransferServer.execute(server);
 *    
 *    oder
 *    
 *    FTServer server = new FTServer();
 *    server.setPort(29467);
 *    server.boot();
 *    executorFileTransferServer = Executors.newSingleThreadExecutor();
 *    executorFileTransferServer.execute(server);
 * 
 * 
 * 
 * </pre>
 * 
 * @author llange
 *
 */
public class FTServer implements Runnable {

  // private List<Socket> clientSockets = Collections.synchronizedList(new
  // LinkedList<Socket>());
  private static Map<Integer, Socket> clientSockets = Collections.synchronizedMap(new HashMap<>());
  private final Logger log = LogManager.getLogger(FTServer.class);
  private final int PAYLOAD_LEN = 7600 * 3 / 4;
  private final int backlog = 5;
  private int port;
  private ServerSocket serverSocket;
  private ExecutorService executorPool;
  private boolean isAlive;

  /**
   * Der File Transfer Server wird erzeugt aber nicht gestartet.
   * 
   */
  public FTServer() {
    isAlive = false;
    executorPool = Executors.newFixedThreadPool(backlog);
  }



  /**
   * Der File Transfer Server wird erzeugt aber nicht gestartet.
   * 
   * @param port
   *             auf diesem Port wird gehorcht
   * @throws PortException
   *                       ungültiger Port
   * @throws BindException
   *                       der Port ist schon belegt
   * @throws IOException
   *                       der Server konnte nicht initialisiert werden
   */
  public FTServer(int port) throws PortException, BindException, IOException {
    this();
    setPort(port);
    boot();
  }



  /**
   * Der Server wird hochgefahren. Erst jetzt können Anfragen entgegengenommen
   * werden.
   * 
   * @throws IOException
   *                       der Server konnte nicht initialisiert werden
   * @throws BindException
   *                       der Port wird schon benutzt
   */
  public void boot() throws IOException, BindException {
    if (isAlive()) return;
    serverSocket = new ServerSocket(getPort(), backlog);
    log.info("(Host/Port) -----> (" + serverSocket.getInetAddress().getHostAddress() + "/" + getPort() + ")");
  }



  private void destroyPool() {
    log.info("server socket pool zerstören");

    for (Socket socket : clientSockets.values()) {
      try {
        socket.close();
      }
      catch (IOException e) {
        log.error(e.getMessage(), e.getCause());
      }
    }
    clientSockets.clear();

    executorPool.shutdown();
    try {
      boolean done = executorPool.awaitTermination(2, TimeUnit.SECONDS);
      if (!done) executorPool.shutdownNow();
    }
    catch (InterruptedException e) {
      log.error(e.getMessage(), e.getCause());
    }
  }



  /**
   * Eingehende Botschaften werden über einen Thread abgearbeitet.
   * 
   * 
   * @param socket
   *               client socket
   * @throws IOException
   * @throws DecodeException
   *                         eine Protokollboschaft konnte nicht decodiert werden
   * @throws EncodeException
   *                         eine Protokollboschaft konnte nicht codiert werden
   */
  private void doWork(Socket socket) throws IOException, DecodeException, EncodeException {
    final int BUFFER_SIZE = 8192;

    socket.setReceiveBufferSize(BUFFER_SIZE);
    socket.setSendBufferSize(BUFFER_SIZE);
    BufferedReader inbuffer = new BufferedReader(
        new InputStreamReader(socket.getInputStream(), "UTF-8"), BUFFER_SIZE
    );
    BufferedWriter outbuffer = new BufferedWriter(
        new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), BUFFER_SIZE
    );
    String read = null;
    while ((read = inbuffer.readLine()) != null) {
      MessageDecoder decoder = new MessageDecoder();
      if (log.isDebugEnabled()) log.debug(read);
      MESSAGE message = decoder.decode(read);
      MESSAGE response = parser(message);
      send(response, outbuffer);
    }
  }



  /**
   * Der Dateiinhalt wird in das Base64-Format konvertiert. Die Datei wird in
   * mehrere Blöcke gesplittet. Ein Block umfasst 5700 Bytes.
   * 
   * 
   * 
   * @param pathtofile
   *                   der Inhalt dieser Datei wird konvertiert.
   * @param blockindex
   *                   Index-Nr * 5700 ergibt die Position in der Datei
   * @throws FileNotFoundException
   *                               die DAtei ist nicht vorhanden
   */
  public byte[] encodeBase64(Path pathtofile, int blockindex) throws FileNotFoundException {
    return encodeUTF8Base64(pathtofile.toAbsolutePath().toString(), blockindex);
  }



  /**
   * Der Dateiinhalt wird in das Base64-Format konvertiert. Die Datei wird in
   * mehrere Blöcke gesplittet. Ein Block umfasst 5700 Bytes.
   * 
   * 
   * @param pathToFile
   *                   der Inhalt dieser Datei wird konvertiert.
   * @param blockindex
   *                   Index-Nr * 5700 ergibt die Position in der Datei
   * @return die konvertierten Bytes im ASCII-Bereich 64-128
   * @throws FileNotFoundException
   *                               die Datei ist nicht vorhanden
   */
  public byte[] encodeUTF8Base64(String pathToFile, int blockindex) throws FileNotFoundException {
    if (blockindex < 0)
      throw new IllegalArgumentException("blockindex is negative, " + String.valueOf(blockindex));
    byte result64[] = new byte[0];

    // int help = FileTransferServer.BUFFER_SIZE * 3 / 4 / 1000;
    // int neu = help * 1000;
    // final int len = 7600 * 3 / 4; // VISTA

    ByteArrayOutputStream out = null;
    byte[] buffer = new byte[PAYLOAD_LEN];
    FileInputStream inputfile = new FileInputStream(pathToFile);
    BufferedInputStream inbuffer = new BufferedInputStream(inputfile, PAYLOAD_LEN);
    try {
      inbuffer.skip(blockindex * PAYLOAD_LEN);
      int count = inbuffer.read(buffer);
      if (count == -1) return result64;
      out = new ByteArrayOutputStream(count);
      out.write(buffer, 0, count);
      out.flush();
      result64 = Base64.getEncoder().encode(out.toByteArray());
    }
    catch (IOException e) {}
    finally {
      try {
        if (inbuffer != null) inbuffer.close();
      }
      catch (IOException e) {}
      try {
        if (out != null) out.close();
      }
      catch (IOException e) {}
    }
    return result64;
  }



  public long getPayloadLength() {
    return PAYLOAD_LEN;
  }



  /**
   * Auf welchem Port wird gehorcht?
   * 
   * @return ein Port im Bereich 1025 - 65535
   */
  public int getPort() {
    return port;
  }



  public synchronized boolean isAlive() {
    return isAlive;
  }



  /**
   * 
   * 
   * 
   * @param process
   *                ein Rohtext
   * @throws IOException
   */
  MESSAGE parser(MESSAGE request) {

    if (request instanceof TRANSFER transfer) {
      if (REQUEST == request.getHeader()) {
        try {
          Path file = Paths.get(transfer.getPathfile(), transfer.getFilename());
          if (!Files.exists(file)) {
            log.warn("The file " + file.toAbsolutePath().toString() + " does not exist.");
            TRANSFER response = new TRANSFER();
            response.setCommand(Command.TRANSFER);
            response.setHeader(ERROR);
            response.setDataset(Protocol.DATASET);
            response.setErrorMessage(file.toAbsolutePath().toString() + " ist nicht vorhanden");
            response.setSlot(transfer.getSlot());
            response.setFilename(transfer.getFilename());
            response.setPathfile(transfer.getPathfile());
            return response;
          }
          byte[] encoded = encodeBase64(file, transfer.getBlockindex());
          // do something
          TRANSFER response = new TRANSFER();
          response.setCommand(Command.TRANSFER);
          response.setHeader(RESPONSE);
          response.setDataset(Protocol.DATASET);
          response.setEndflag(encoded.length == 0);
          response.setFilename(transfer.getFilename());
          response.setPathfile(transfer.getPathfile());
          response.setPayload(new String(encoded));
          response.setBlockindex(transfer.getBlockindex());
          response.setSlot(transfer.getSlot());
          response.setMaxDatablocks(transfer.getMaxDatablocks());
          return response;
        }
        catch (InvalidPathException e) {
          log.fatal(e.getMessage());
        }
        catch (FileNotFoundException e1) {}
      }
    }
    else if (request instanceof FTSDOWNLOAD ftsdownloadRequest) {
      if (REQUEST == request.getHeader()) {
        // BLOCKS berechnen
        FTSDOWNLOAD response = new FTSDOWNLOAD();
        try {
          Path file = Paths.get(ftsdownloadRequest.getPath(), ftsdownloadRequest.getFilename());
          if (!Files.exists(file)) {
            // CANCELED
            log.error("Die Datei <" + file.toAbsolutePath().toString() + "> ist nicht mehr vorhanden.");
            response.setCommand(Command.FTSDOWNLOAD);
            response.setHeader(ERROR);
            response.setDataset(Protocol.DATASET);
            response.setErrorMessage(
                "Die Datei <\" + file.toAbsolutePath().toString() + \"> ist nicht mehr vorhanden."
            );
            response.setText("Die Datei wurde gelöscht.");
            response.setFilename(ftsdownloadRequest.getFilename());
            response.setPath(ftsdownloadRequest.getPath());
            response.setSlot(ftsdownloadRequest.getSlot());
            return response;
          }
          try {
            long value = Files.size(file) % PAYLOAD_LEN;
            int databloacks = Long.valueOf(Files.size(file) / PAYLOAD_LEN).intValue();
            if (value > 0) {
              databloacks++;
            }
            response.setCommand(Command.FTSDOWNLOAD);
            response.setHeader(RESPONSE);
            response.setDataset(Protocol.DATASET);
            response.setFilename(ftsdownloadRequest.getFilename());
            response.setPath(ftsdownloadRequest.getPath());
            response.setSlot(ftsdownloadRequest.getSlot());
            response.setMaxDatablocks(databloacks);
          }
          catch (IOException e) {}
        }
        catch (InvalidPathException e) {
          response.setCommand(Command.FTSDOWNLOAD);
          response.setHeader(ERROR);
          response.setDataset(Protocol.DATASET);
          response.setErrorMessage(e.getMessage());
          response.setText("Der Dateiname ist ungültig");
          response.setFilename(ftsdownloadRequest.getFilename());
          response.setPath(ftsdownloadRequest.getPath());
          response.setSlot(ftsdownloadRequest.getSlot());
        }
        return response;
      }
    }
    return new MESSAGE();
  }



  @Override
  public void run() {
    int key = 0;
    isAlive = true;
    while (isAlive) {
      Socket socket;
      try {
        socket = serverSocket.accept();
        clientSockets.put(key++, socket);
        Runnable runnable = () -> {
          try {
            doWork(socket);
          }
          catch (IOException | DecodeException | EncodeException e) {
            log.error(e.getMessage(), e.getCause());
          }
        };
        executorPool.execute(runnable);
      }
      catch (IOException e1) {
        log.info("server socket in run");
        isAlive = false;
      }
    }
    destroyPool();
  }



  void send(MESSAGE message, BufferedWriter outbuffer) throws EncodeException, UnsupportedEncodingException {
    MessageEncoder encoder = new MessageEncoder();
    String protocol = encoder.encode(message);
    if (log.isDebugEnabled()) log.debug(protocol);
    try {
      outbuffer.write(protocol);
      outbuffer.newLine();
      outbuffer.flush();
    }
    catch (IOException e) {
      log.warn(e.getMessage(), e);
    }
  }



  /**
   * Auf diesem Port wird gehorcht.
   * 
   * @param value
   *              ein Port im Bereich 1025 - 65535
   * @throws PortException
   *                       ungültiger Port
   */
  public void setPort(int value) throws PortException {
    if (1025 > value || value > 65535)
      throw new PortException(String.valueOf(value) + " -----> valid port range [1025...65535]");
    port = value;
  }



  /**
   * Der Server wird heruntergefahren und alle bestehenden Verbindungen
   * geschlossen. Der Aufruf erfolgt nicht vom implementierten Thread.
   * 
   */
  public void shutdown() {
    if (serverSocket == null) {
      return;
    }
    try {
      serverSocket.close();
    }
    catch (IOException e) {
      log.error(e.getMessage(), e.getCause());
    }
  }

}
