package com.airlock.iam.core.misc.util.ticket.codec.esp;

import com.airlock.iam.base.domain.service.uri.UrlUtils;
import com.airlock.iam.core.misc.util.ticket.KeyMultiValue;
import com.airlock.iam.core.misc.util.ticket.Ticket;
import com.airlock.iam.core.misc.util.ticket.TicketDecoder;
import com.airlock.iam.core.misc.util.ticket.TicketEncodingException;
import com.airlock.iam.core.misc.util.ticket.codec.esp.EspSignTicketDecoder.EspSignTicketDecoderBuilder;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.plugin.api.annotation.config.Plugin;
import com.airlock.iam.plugin.api.annotation.lifecycle.AfterConfigActivation;
import com.airlock.iam.plugin.api.exception.InitializeException;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.math.BigInteger;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.Signature;
import java.security.SignatureException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.text.ParseException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.StringTokenizer;
import org.apache.commons.codec.binary.Base64;

@Plugin(
   description = "Ticket decoder decoding digitally signed tickets with a fix 512-bit RSA public key located in the ticket encoder code.<p><b>Note:</b>In order for the encoder to be compliant with legacy code, the encoding of the ticket information is not straight forward and not like in the other ticket encoders.</p><p>Use the ticket encoder <class>EspSignTicketEncoder</class> to encode tickets to be decoded by this decoder.</p>"
)
public class EspSignTicketDecoder implements TicketDecoder {
   private static final ELogger LOG = ELoggerFactory.getLogger(EspSignTicketDecoder.class);
   public static final Charset URL_ENCODING = StandardCharsets.UTF_8;
   public static final byte SIGN_COOKIE_ENCODING = 1;
   public static final String USER_IDS_SECTION = "userids:";
   public static final String ROLES_SECTION = "roles:";
   public static final char END_OF_SECTION_TOKEN = ';';
   public static final String SEPARATION_TOKEN = ",";
   public static final String USERNAME_KEY_IN_COOKIE = "eServiceUser";
   public static final String GSID_KEY_IN_COOKIE = "EAAS_GSID";
   public static final String DN_IN_COOKIE = "DISTINGUISHED_NAME";
   public static final String AUTH_METHOD_KEY_IN_COOKIE = "AUTH_METHOD";
   public static final String USERNAME_KEY_IN_TICKET = "username";
   public static final String GSID_KEY_IN_TICKET = "EAAS_GSID";
   public static final String DN_KEY_IN_TICKET = "DISTINGUISHED_NAME";
   public static final String AUTH_METHOD_KEY_IN_TICKET = "AUTH_METHOD";
   public static final String ROLES_KEY_IN_TICKET = "roles";
   static final byte[] MODULUS = new byte[]{
      0,
      -54,
      -104,
      38,
      -70,
      6,
      -40,
      56,
      16,
      -127,
      -28,
      8,
      113,
      77,
      -22,
      -110,
      1,
      3,
      -79,
      57,
      -99,
      -114,
      31,
      -94,
      -85,
      -62,
      -116,
      43,
      -110,
      39,
      -28,
      -28,
      37,
      21,
      -112,
      39,
      -96,
      123,
      -126,
      -27,
      32,
      45,
      -78,
      4,
      93,
      -115,
      117,
      -5,
      55,
      -33,
      93,
      74,
      -59,
      46,
      -113,
      -24,
      55,
      10,
      101,
      -97,
      -15,
      78,
      97,
      95,
      27
   };
   private static final byte[] PUBLIC_EXPONENT = new byte[]{1, 0, 1};
   private static final Object VERIFIER_LOCK = new Object();
   private static Signature verifier;

   public Ticket decode(String ticketString, String urlEncodingScheme) throws TicketEncodingException {
      byte[] cookieBytes = Base64.decodeBase64(ticketString);
      if (cookieBytes.length < 2) {
         throw new TicketEncodingException("value too short after URL-decoding and base-64-decoding");
      }

      if (cookieBytes[0] != 1) {
         throw new TicketEncodingException("Wrong cookie encoding indicator. Expected 1 but got " + cookieBytes[0]);
      }

      byte[] bodyBytes = new byte[cookieBytes.length - 1 - getSignatureLength()];
      System.arraycopy(cookieBytes, 1 + getSignatureLength(), bodyBytes, 0, bodyBytes.length);
      byte[] signature = new byte[getSignatureLength()];
      System.arraycopy(cookieBytes, 1, signature, 0, signature.length);

      try {
         if (!this.isSignatureValid(bodyBytes, 0, bodyBytes.length, signature)) {
            throw new SignatureException("could not verify signature on authentication cookie");
         }
      } catch (SignatureException e) {
         throw new TicketEncodingException("Error while verifying digital signature (" + e.getMessage() + ")", e);
      }

      Instant expiry = Instant.ofEpochMilli(bytesToLong(bodyBytes, bodyBytes.length - 8));
      if (expiry.isBefore(Instant.now())) {
         throw new TicketEncodingException("Authentication ticket expired on " + Date.from(expiry));
      }

      String decodedCookieString = new String(bodyBytes, 0, bodyBytes.length - 8, StandardCharsets.ISO_8859_1);
      Ticket res = new Ticket(expiry);

      try {
         parseCookieString(res, decodedCookieString);
         return res;
      } catch (ParseException e) {
         throw new TicketEncodingException("Error while parsing the encoded cookie string (" + e.getMessage() + ")", e);
      }
   }

   private static void parseCookieString(Ticket ticket, String ticketString) throws ParseException {
      if (ticket == null) {
         throw new IllegalArgumentException("ticket is null");
      }

      if (ticketString == null) {
         throw new IllegalArgumentException("ticket string is null");
      }

      int pos = 0;
      int userIDsTokenPos = ticketString.indexOf("userids:");
      if (userIDsTokenPos >= 0) {
         pos = userIDsTokenPos + "userids:".length();
         int endOfUserIdsPos = ticketString.indexOf(59, pos);
         if (endOfUserIdsPos < 0) {
            throw new ParseException("Token ending the user ids not found: \";\"", pos);
         }

         String userIdSection = ticketString.substring(pos, endOfUserIdsPos);
         StringTokenizer st = new StringTokenizer(userIdSection, ",");

         while (st.hasMoreTokens()) {
            String nameValuePair = st.nextToken();
            int equalsPos = nameValuePair.indexOf(61);
            if (equalsPos < 0) {
               LOG.debug("Expected \"<name>=<value>\" in string \"" + nameValuePair + "\"");
               throw new ParseException("Expected \"<name>=<value>\"", pos);
            }

            String key = nameValuePair.substring(0, equalsPos);
            String value = nameValuePair.substring(equalsPos + 1);
            if ("eServiceUser".equals(key)) {
               ticket.addKeyMultiValue(new KeyMultiValue("username", urlDecode(value)));
            } else {
               ticket.addKeyMultiValue(new KeyMultiValue(urlDecode(key), urlDecode(value)));
            }
         }
      }

      int rolesTokenPos = ticketString.indexOf("roles:");
      if (rolesTokenPos < 0) {
         throw new ParseException("Expected token \"roles:\"", pos);
      }

      pos = rolesTokenPos + "roles:".length();
      int endOfRolesPos = ticketString.indexOf(59, pos);
      if (endOfRolesPos < 0) {
         throw new ParseException("Token ending the roles not found: \";\"", pos);
      }

      String rolesSection = ticketString.substring(pos, endOfRolesPos);
      StringTokenizer st = new StringTokenizer(rolesSection, ",");
      List<String> roles = new ArrayList<>();

      while (st.hasMoreTokens()) {
         roles.add(urlDecode(st.nextToken()));
      }

      ticket.addKeyMultiValue(new KeyMultiValue("roles", roles.toArray(String[]::new)));
      pos = ticketString.indexOf(59, pos);
      if (++pos < ticketString.length() - 1) {
         int endOfNvPos = ticketString.indexOf(59, pos);
         if (endOfNvPos < 0) {
            throw new ParseException("Token ending the name/values not found: \";\"", pos);
         }

         String nvSection = ticketString.substring(pos, endOfNvPos);
         st = new StringTokenizer(nvSection, ",");

         while (st.hasMoreTokens()) {
            String nvPair = st.nextToken();
            int equalsPos = nvPair.indexOf(61);
            if (equalsPos < 0) {
               LOG.debug("Expected \"<name>=<value>\" in string \"" + nvPair + "\"");
               throw new ParseException("Expected \"<name>=<value>\"", pos);
            }

            String key = nvPair.substring(0, equalsPos);
            String value = nvPair.substring(equalsPos + 1);
            ticket.addKeyMultiValue(new KeyMultiValue(urlDecode(key), urlDecode(value)));
         }
      }
   }

   @AfterConfigActivation
   void preprocessConfiguration() throws InitializeException {
      this.initKeys();
   }

   private void initKeys() throws InitializeException {
      RSAPublicKeySpec pubKeySpec = new RSAPublicKeySpec(new BigInteger(MODULUS), new BigInteger(PUBLIC_EXPONENT));

      try {
         synchronized (VERIFIER_LOCK) {
            RSAPublicKey publicKey = (RSAPublicKey)KeyFactory.getInstance("RSA").generatePublic(pubKeySpec);
            verifier = Signature.getInstance("SHA1withRSA");
            verifier.initVerify(publicKey);
         }
      } catch (InvalidKeySpecException | NoSuchAlgorithmException | InvalidKeyException e) {
         throw new InitializeException("Error initializing RSA verifier (" + e.getMessage() + ")", e);
      }
   }

   protected static String urlDecode(String s) {
      if (s == null) {
         throw new IllegalArgumentException("s is null");
      } else {
         return UrlUtils.urlDecode(s, URL_ENCODING);
      }
   }

   protected static int getSignatureLength() {
      return MODULUS.length / 8 * 8;
   }

   private boolean isSignatureValid(byte[] buffer, int offset, int length, byte[] signature) throws SignatureException {
      if (buffer == null) {
         throw new IllegalArgumentException("input is null");
      }

      if (signature == null) {
         throw new IllegalArgumentException("signature is null");
      }

      if (offset < 0) {
         throw new IllegalArgumentException("offset is negative");
      }

      if (length < 0) {
         throw new IllegalArgumentException("length is negative");
      }

      synchronized (VERIFIER_LOCK) {
         verifier.update(buffer, offset, length);
         return verifier.verify(signature);
      }
   }

   public static long bytesToLong(byte[] bytes, int offset) {
      if (bytes == null) {
         throw new IllegalArgumentException("input byte array is null");
      }

      if (bytes.length < offset + 8) {
         throw new IllegalArgumentException("byte array is too short or offset too large");
      }

      int bitsInAByte = 8;
      return (bytes[offset + 0] & 255L) << 56
         | (bytes[offset + 1] & 255L) << 48
         | (bytes[offset + 2] & 255L) << 40
         | (bytes[offset + 3] & 255L) << 32
         | (bytes[offset + 4] & 255L) << 24
         | (bytes[offset + 5] & 255L) << 16
         | (bytes[offset + 6] & 255L) << 8
         | (bytes[offset + 7] & 255L) << 0;
   }

   @SuppressFBWarnings(justification = "generated code")
   public static EspSignTicketDecoderBuilder builder() {
      return new EspSignTicketDecoderBuilder();
   }
}
