/*
 * Copyright (C) 2020 Finn Herzfeld
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package io.finn.signald;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.asamk.signal.*;
import org.asamk.signal.storage.contacts.ContactInfo;
import org.asamk.signal.util.Hex;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.*;
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
import org.whispersystems.signalservice.api.push.exceptions.EncapsulatedExceptions;
import org.whispersystems.signalservice.api.push.exceptions.NetworkFailureException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.internal.util.Base64;

import java.io.*;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeoutException;

public class SocketHandler implements Runnable {
  private BufferedReader reader;
  private PrintWriter writer;
  private ConcurrentHashMap<String,MessageReceiver> receivers;
  private ObjectMapper mpr = new ObjectMapper();
  private static final Logger logger = LogManager.getLogger();
  private Socket socket;
  private ArrayList<String> subscribedAccounts = new ArrayList<>();

  public SocketHandler(Socket socket, ConcurrentHashMap<String,MessageReceiver> receivers) throws IOException {
    this.reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    this.writer = new PrintWriter(socket.getOutputStream(), true);
    this.socket = socket;
    this.receivers = receivers;

    this.mpr.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); // disable autodetect
    this.mpr.setSerializationInclusion(Include.NON_NULL);
    this.mpr.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    this.mpr.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
  }

  public void run() {
    logger.info("Client connected");

    try {
      reply("version", new JsonVersionMessage(), null);

      while (true) {
        final String line = reader.readLine();

        /* client disconnected */
        if (line == null) {
          logger.info("Client disconnected");
          break;
        }

        /* client sent whitespace -- ignore */
        if (line.trim().length() == 0) {
          continue;
        }

        logger.debug(line);

        JsonRequest request = null;

        try {
          request = mpr.readValue(line, JsonRequest.class);
          handleRequest(request);
        } catch(JsonProcessingException e) {
          handleError(e, null);
        } catch (Throwable e) {
          handleError(e, request);
        }
      }

    } catch(IOException e) {
      handleError(e, null);
    } finally {

      try {
        reader.close();
        writer.close();
      } catch (IOException e) {
        logger.catching(e);
      }

      for(Map.Entry<String, MessageReceiver> entry : receivers.entrySet()) {
        if(entry.getValue().unsubscribe(socket)) {
          logger.info("Unsubscribed from " + entry.getKey());
          receivers.remove(entry.getKey());
        }
      }
    }
  }

  private void handleRequest(JsonRequest request) throws Throwable {
    switch(request.type) {
      case "send":
        send(request);
        break;
      case "mark_read":
        markRead(request);
        break;
      case "subscribe":
        subscribe(request);
        break;
      case "unsubscribe":
        unsubscribe(request);
        break;
      case "list_accounts":
        listAccounts(request);
        break;
      case "register":
        register(request);
        break;
      case "verify":
        verify(request);
        break;
      case "link":
        link(request);
        break;
      case "add_device":
        addDevice(request);
        break;
      case "update_group":
        updateGroup(request);
        break;
      case "set_expiration":
        setExpiration(request);
        break;
      case "list_groups":
        listGroups(request);
        break;
      case "leave_group":
       leaveGroup(request);
       break;
      case "get_user":
        getUser(request);
        break;
      case "get_identities":
        getIdentities(request);
        break;
      case "trust":
        trust(request);
        break;
      case "sync_contacts":
        syncContacts(request);
        break;
      case "list_contacts":
        listContacts(request);
        break;
      case "update_contact":
        updateContact(request);
        break;
      case "version":
        version();
        break;
      case "get_profile":
        getProfile(request);
        break;
      case "set_profile":
        setProfile(request);
        break;
      default:
        logger.warn("Unknown command type " + request.type);
        this.reply("unknown_command", new JsonStatusMessage(5, "Unknown command type " + request.type, request), request.id);
        break;
    }
  }

  private void send(JsonRequest request) throws IOException, UntrustedIdentityException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, NoSuchAccountException {
    Manager manager = Manager.get(request.username);

    SignalServiceDataMessage.Quote quote = null;

    if(request.quote != null) {
      quote = request.quote.getQuote();
    }

    if(request.attachmentFilenames != null) {
      logger.warn("Using deprecated attachmentFilenames argument for send! Use attachments instead");
      if(request.attachments == null) {
        request.attachments = new ArrayList<>();
      }
      for(String attachmentFilename: request.attachmentFilenames) {
        request.attachments.add(new JsonAttachment(attachmentFilename));
      }
    }

    List<SignalServiceAttachment> attachments = null;
    if (request.attachments != null) {
        attachments = new ArrayList<>(request.attachments.size());
        for (JsonAttachment attachment : request.attachments) {
            try {
                File attachmentFile = new File(attachment.filename);
                InputStream attachmentStream = new FileInputStream(attachmentFile);
                final long attachmentSize = attachmentFile.length();
                String mime = Files.probeContentType(attachmentFile.toPath());
                if (mime == null) {
                    mime = "application/octet-stream";
                }

                attachments.add(new SignalServiceAttachmentStream(attachmentStream, mime, attachmentSize, Optional.of(attachmentFile.getName()), attachment.voiceNote, attachment.getPreview(), attachment.width, attachment.height, Optional.fromNullable(attachment.caption), Optional.fromNullable(attachment.blurhash), null));
            } catch (IOException e) {
                throw new AttachmentInvalidException(attachment.filename, e);
            }
        }
    }

    try {
      List<SendMessageResult> sendMessageResults;
      if(request.recipientGroupId != null) {
        byte[] groupId = Base64.decode(request.recipientGroupId);
        sendMessageResults = manager.sendGroupMessage(request.messageBody, attachments, groupId, quote);
      } else {
        sendMessageResults = manager.sendMessage(request.messageBody, attachments, request.recipientNumber, quote);
      }

      for(SendMessageResult result: sendMessageResults) {
        SendMessageResult.Success success = result.getSuccess();
        if(success != null) {
          if(success.isUnidentified()) {
            this.reply("success", new JsonStatusMessage(0, "successfully send unidentified message"), request.id);
          }
          if(success.isNeedsSync()) {
            this.reply("success", new JsonStatusMessage(1, "isNeedsSync = true"), request.id);
          }
        }

        if(result.isNetworkFailure()) {
          // TODO: Log more info about what message failed, who it failed to, and any other info needed to resend
          this.reply("network_failure", null, request.id);
        }

        if(result.isUnregisteredFailure()) {
          this.reply("unregistered_user", null, request.id);
        }

        SendMessageResult.IdentityFailure identityFailure = result.getIdentityFailure();
        if(identityFailure != null) {
          this.reply("untrusted_identity", new JsonUntrustedIdentityException(identityFailure.getIdentityKey(), result.getAddress().getNumber(), manager, request), request.id);
        }

      }
    } catch(EncapsulatedExceptions e) {
      for(UnregisteredUserException i: e.getUnregisteredUserExceptions()) {
        this.reply("unregistered_user", new JsonUnregisteredUserException(i), request.id);
      }

      for(NetworkFailureException i: e.getNetworkExceptions()) {
        this.reply("network_failure", new JsonNetworkFailureException(i), request.id);
      }
    }
  }

  private void markRead(JsonRequest request) throws IOException, NoSuchAccountException {
    logger.info("Mark as Read");
    Manager m = Manager.get(request.username);

    if(request.when == 0) {
      request.when = System.currentTimeMillis();
    }

    SignalServiceReceiptMessage message = new SignalServiceReceiptMessage(
        SignalServiceReceiptMessage.Type.READ,
        request.timestamps,
        request.when);

    SendMessageResult result = m.sendReceipt(message, request.recipientNumber);
    if(result != null) {
      SendMessageResult.IdentityFailure identityFailure = result.getIdentityFailure();
      if(identityFailure != null) {
        this.reply("untrusted_identity", new JsonUntrustedIdentityException(identityFailure.getIdentityKey(), result.getAddress().getNumber(), m, request), request.id);
      }
    }
  }

  private void listAccounts(JsonRequest request) throws IOException {
    JsonAccountList accounts = new JsonAccountList(subscribedAccounts);
    this.reply("account_list", accounts, request.id);
  }

  private void register(JsonRequest request) throws IOException, NoSuchAccountException {
    logger.info("Register request: " + request);
    Manager m = Manager.get(request.username, true);
    Boolean voice = false;
    if(request.voice != null) {
      voice = request.voice;
    }

    if(!m.userHasKeys()) {
      logger.info("User has no keys, making some");
      m.createNewIdentity();
    }
    logger.info("Registering (voice: " + voice + ")");
    m.register(voice, Optional.fromNullable(request.captcha));
    this.reply("verification_required", new JsonAccount(m), request.id);
  }

  private void verify(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    if(!m.userHasKeys()) {
      logger.warn("User has no keys, first call register.");
    } else if(m.isRegistered()) {
      logger.warn("User is already verified");
    } else {
      logger.info("Submitting verification code " + request.code + " for number " + request.username);
      m.verifyAccount(request.code);
      this.reply("verification_succeeded", new JsonAccount(m), request.id);
    }
  }

  private void addDevice(JsonRequest request) throws IOException, InvalidKeyException, AssertionError, URISyntaxException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    m.addDeviceLink(new URI(request.uri));
    reply("device_added", new JsonStatusMessage(4, "Successfully linked device"), request.id);
  }

  private void updateGroup(JsonRequest request) throws IOException, EncapsulatedExceptions, UntrustedIdentityException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, NoSuchAccountException {
    Manager m = Manager.get(request.username);

    byte[] groupId = null;
    if(request.recipientGroupId != null) {
      groupId = Base64.decode(request.recipientGroupId);
    }
    if (groupId == null) {
        groupId = new byte[0];
    }

    String groupName = request.groupName;
    if(groupName == null) {
        groupName = "";
    }

    List<String> groupMembers = request.members;
    if (groupMembers == null) {
        groupMembers = new ArrayList<>();
    }

    String groupAvatar = request.avatar;
    if (groupAvatar == null) {
        groupAvatar = "";
    }

    byte[] newGroupId = m.updateGroup(groupId, groupName, groupMembers, groupAvatar);

    if (groupId.length != newGroupId.length) {
        this.reply("group_created", new JsonStatusMessage(5, "Created new group " + groupName + "."), request.id);
    } else {
        this.reply("group_updated", new JsonStatusMessage(6, "Updated group"), request.id);
    }
  }

  private void setExpiration(JsonRequest request) throws IOException, GroupNotFoundException, NotAGroupMemberException, AttachmentInvalidException, UntrustedIdentityException, EncapsulatedExceptions, NoSuchAccountException {
    Manager m = Manager.get(request.username);

    if(request.recipientGroupId != null) {
      byte[] groupId = Base64.decode(request.recipientGroupId);
      m.setExpiration(groupId, request.expiresInSeconds);
    } else {
      m.setExpiration(request.recipientNumber, request.expiresInSeconds);
    }

    this.reply("expiration_updated", null, request.id);
  }

  private void listGroups(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    this.reply("group_list", new JsonGroupList(m), request.id);
  }

  private void leaveGroup(JsonRequest request) throws IOException, GroupNotFoundException, UntrustedIdentityException, NotAGroupMemberException, EncapsulatedExceptions, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    byte[] groupId = Base64.decode(request.recipientGroupId);
    m.sendQuitGroupMessage(groupId);
    this.reply("left_group", new JsonStatusMessage(7, "Successfully left group"), request.id);
  }

  private void reply(String type, Object data, String id) throws JsonProcessingException {
    JsonMessageWrapper message = new JsonMessageWrapper(type, data, id);
    String jsonmessage = this.mpr.writeValueAsString(message);
    PrintWriter out = new PrintWriter(this.writer, true);
    out.println(jsonmessage);
  }


  private void link(JsonRequest request) throws AssertionError, IOException, InvalidKeyException {
    Manager m = new Manager(null);
    m.createNewIdentity();
    String deviceName = "signald"; // TODO: Set this to "signald on <hostname>"
    if(request.deviceName != null) {
      deviceName = request.deviceName;
    }
    try {
      m.getDeviceLinkUri();
      this.reply("linking_uri", new JsonLinkingURI(m), request.id);
      m.finishDeviceLink(deviceName);
      this.reply("linking_successful", new JsonAccount(m), request.id);
    } catch(TimeoutException e) {
      this.reply("linking_error", new JsonStatusMessage(1, "Timed out while waiting for device to link", request), request.id);
    } catch(UserAlreadyExists e) {
      this.reply("linking_error", new JsonStatusMessage(3, "The user " + e.getUsername() + " already exists. Delete \"" + e.getFileName() + "\" and trying again.", request), request.id);
    }
  }

  private void getUser(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    Optional<ContactTokenDetails> contact = m.getUser(request.recipientNumber);
    if(contact.isPresent()) {
      this.reply("user", new JsonContactTokenDetails(contact.get()), request.id);
    } else {
      this.reply("user_not_registered", null, request.id);
    }
  }

  private void getIdentities(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    this.reply("identities", new JsonIdentityList(request.recipientNumber, m), request.id);
  }

  private void trust(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    TrustLevel trustLevel = TrustLevel.TRUSTED_VERIFIED;
    if(request.fingerprint == null) {
      this.reply("input_error", new JsonStatusMessage(0, "Fingerprint must be a string!", request), request.id);
      return;
    }
    if(request.trustLevel != null) {
      try {
        trustLevel = TrustLevel.valueOf(request.trustLevel.toUpperCase());
      } catch(IllegalArgumentException e) {
        this.reply("input_error",
                new JsonStatusMessage(0, "Invalid TrustLevel", request), request.id);
        return;
      }
    }
    String fingerprint = request.fingerprint.replaceAll(" ", "");
    if (fingerprint.length() == 66) {
      byte[] fingerprintBytes;
      fingerprintBytes = Hex.toByteArray(fingerprint.toLowerCase(Locale.ROOT));
      boolean res = m.trustIdentity(request.recipientNumber, fingerprintBytes, trustLevel);
      if (!res) {
        this.reply("trust_failed", new JsonStatusMessage(0, "Failed to set the trust for the fingerprint of this number, make sure the number and the fingerprint are correct.", request), request.id);
      } else {
        this.reply("trusted_fingerprint", new JsonStatusMessage(0, "Successfully trusted fingerprint", request), request.id);
      }
    } else if (fingerprint.length() == 60) {
      boolean res = m.trustIdentitySafetyNumber(request.recipientNumber, fingerprint, trustLevel);
      if (!res) {
        this.reply("trust_failed", new JsonStatusMessage(0, "Failed to set the trust for the safety number of this number, make sure the number and the safety number are correct.", request), request.id);
      } else {
        this.reply("trusted_safety_number", new JsonStatusMessage(0, "Successfully trusted safety number", request), request.id);
      }
    } else {
      System.err.println("Fingerprint has invalid format, either specify the old hex fingerprint or the new safety number");
      this.reply("trust_failed", new JsonStatusMessage(0, "Fingerprint has invalid format, either specify the old hex fingerprint or the new safety number", request), request.id);
    }
  }

  private void syncContacts(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    m.requestSyncContacts();
    this.reply("sync_requested", null, request.id);
  }

  private void listContacts(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    this.reply("contact_list", m.getContacts(), request.id);
  }

  public void updateContact(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager m = Manager.get(request.username);
    if(request.contact == null) {
      this.reply("update_contact_error", new JsonStatusMessage(0, "No contact specificed!", request), request.id);
      return;
    }

    if(request.contact.number == null) {
      this.reply("update_contact_error", new JsonStatusMessage(0, "No number specified! Contact must have a number", request), request.id);
      return;
    }

    m.updateContact(request.contact);
    this.reply("contact_updated", null, request.id);
  }

  private void subscribe(JsonRequest request) throws IOException, NoSuchAccountException {
    Manager.get(request.username); // throws an exception if the user doesn't exist
    if(!this.receivers.containsKey(request.username)) {
      MessageReceiver receiver = new MessageReceiver(request.username);
      this.receivers.put(request.username, receiver);
      Thread messageReceiverThread = new Thread(receiver);
      messageReceiverThread.start();
    } else {
      logger.debug("Additional subscribe request, re-using existing MessageReceiver");
    }
    this.receivers.get(request.username).subscribe(this.socket);
    this.subscribedAccounts.add(request.username);
    this.reply("subscribed", null, request.id);  // TODO: Indicate if we actually subscribed or were already subscribed, also which username it was for
  }

  private void unsubscribe(JsonRequest request) throws IOException {
    this.receivers.get(request.username).unsubscribe(this.socket);
    this.receivers.remove(request.username);
    this.subscribedAccounts.remove(request.username);
    this.reply("unsubscribed", null, request.id);  // TODO: Indicate if we actually unsubscribed or were already unsubscribed, also which username it was for
  }

  private void version() throws IOException {
      this.reply("version", new JsonVersionMessage(), null);
  }

  private void getProfile(JsonRequest request) throws IOException, InvalidCiphertextException, NoSuchAccountException {
      Manager m = Manager.get(request.username);
      ContactInfo contact = m.getContact(request.recipientNumber);
      if(contact == null || contact.profileKey == null) {
          this.reply("profile_not_available", null, request.id);
          return;
      }
      this.reply("profile", new JsonProfile(m.getProfile(request.recipientNumber), Base64.decode(contact.profileKey)), request.id);
  }

  private void setProfile(JsonRequest request) throws IOException, NoSuchAccountException {
      Manager m = Manager.get(request.username);
      m.setProfileName(request.name);
      this.reply("profile_set", null, request.id);
  }

  private void handleError(Throwable error, JsonRequest request) {
    logger.catching(error);
    String requestid = "";
    if(request != null) {
        requestid = request.id;
    }
    try {
        this.reply("unexpected_error", new JsonStatusMessage(0, error.getMessage(), request), requestid);
    } catch(JsonProcessingException e) {
        logger.catching(error);
    }
  }
}