package com.airlock.iam.common.infrastructure.mapification;

import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.api.domain.model.mapification.Mapifiable;
import com.airlock.iam.base.api.domain.model.mapification.MapifiedValues;
import com.airlock.iam.base.domain.model.mapification.MapificationUtils;
import com.airlock.iam.base.domain.model.mapification.MapifiedValuesBuilder;
import com.airlock.iam.common.domain.service.mapification.Mapifier;
import com.airlock.iam.elog.api.EDefaultLogType;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.system.property.IamSystemProperties;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import jakarta.servlet.http.HttpSession;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;

public final class MapifierImpl implements Mapifier {
   private static final ELogger LOG = ELoggerFactory.getLogger(MapifierImpl.class);
   private static final AtomicLong COUNTER = new AtomicLong();
   private static final String CLASSNAME_KEY = "class";
   private static final String NULL_TYPE = "null";
   private static final String INSTANT_TYPE = "instant";
   private static final String DATE_TYPE = "date";
   private static final String LOCAL_DATE_TYPE = "localDate";
   private static final String URI_TYPE = "uri";
   private static final String PRIMITIVE_TYPE = "primitive";
   private static final String LIST_TYPE = "list";
   private static final String MAP_TYPE = "map";
   private static final String VALUES_KEY = "values";
   private static final String REF_KEY = "ref";
   private static final String REF_PREFIX = "ref-";
   private static final String ENUM_NAME = "name";
   private static final Set<Class<?>> NON_MAPIFABLE_CLASSES = new HashSet<>();
   private final Provider<HttpSession> httpSession;

   @Inject
   public MapifierImpl(Provider<HttpSession> httpSession) {
      this.httpSession = httpSession;
   }

   @Override
   public MapifiedValues mapify(Object object) {
      if (object == null) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "null").build();
      } else if (isPrimitive(object)) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "primitive").put("values", object).build();
      } else if (object instanceof List list) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "list").put("values", list.stream().map(v -> this.mapify(v).asMap()).toList()).build();
      } else if (object instanceof Map map) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "map").put("values", MapificationUtils.mapifyMap(map, this::mapify)).build();
      } else if (object instanceof Instant instant) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "instant").put("values", MapificationUtils.mapifyInstant(instant)).build();
      } else if (object instanceof Date date) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "date").put("values", MapificationUtils.mapifyDate(date)).build();
      } else if (object instanceof LocalDate localDate) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "localDate").put("values", MapificationUtils.mapifyLocalDate(localDate)).build();
      } else if (object instanceof URI uri) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", "uri").put("values", uri.toASCIIString()).build();
      } else if (object instanceof Mapifiable mapifiable) {
         return MapifiedValuesBuilder.mapifiedValues().put("class", object.getClass().getName()).put("values", mapifiable.mapify()).build();
      } else {
         return object instanceof Enum enumObject
            ? MapifiedValuesBuilder.mapifiedValues().put("class", object.getClass().getName()).put("values", Map.of("name", enumObject.name())).build()
            : this.cacheInHttpSession(object);
      }
   }

   private static boolean isPrimitive(Object object) {
      return object instanceof Integer || object instanceof Long || object instanceof String || object instanceof Boolean;
   }

   @Override
   public <T> T demapify(MapifiedValues values) {
      if (values.containsKey("ref")) {
         return (T)this.restoreFromHttpSession(values.get("ref"));
      }

      if (values.get("class").equals("null")) {
         return null;
      }

      if (values.get("class").equals("instant")) {
         return (T)MapificationUtils.demapifyInstant(values.getValues("values"));
      }

      if (values.get("class").equals("date")) {
         return (T)MapificationUtils.demapifyDate(values.getValues("values"));
      }

      if (values.get("class").equals("localDate")) {
         return (T)MapificationUtils.demapifyLocalDate(values.getValues("values"));
      }

      if (values.get("class").equals("uri")) {
         return (T)URI.create(values.get("values"));
      }

      if (values.get("class").equals("primitive")) {
         return values.get("values");
      }

      if (values.get("class").equals("list")) {
         return (T)values.<T>demapifyList("values", this::demapify);
      }

      if (values.get("class").equals("map")) {
         return (T)MapificationUtils.demapifyMap(values.get("values"), this::demapify);
      }

      Function<MapifiedValues, Object> demapifier = demapifier(values.get("class"));
      return (T)demapifier.apply(values.getValues("values"));
   }

   private static Function<MapifiedValues, Object> demapifier(String className) {
      if (DemapificationClassNameChanges.CLASS_NAME_CHANGES.containsKey(className)) {
         String newClassName = DemapificationClassNameChanges.CLASS_NAME_CHANGES.get(className);
         LOG.debug("Migrating class " + className + " to " + newClassName);
         return demapifier(newClassName);
      }

      try {
         Class<?> clazz = Class.forName(className);
         if (Mapifiable.class.isAssignableFrom(clazz)) {
            return demapifyMethod(clazz);
         } else if (clazz.isEnum()) {
            return v -> enumValue(clazz, v.get("name"));
         } else {
            throw new FailureException("Cannot demapify class of type " + clazz.getName());
         }
      } catch (ClassNotFoundException e) {
         throw new FailureException("Class not found for demapification: " + className + ". Class rename?");
      }
   }

   private static Function<MapifiedValues, Object> demapifyMethod(Class<?> clazz) {
      return values -> {
         try {
            Method demapifier = clazz.getMethod("demapify", MapifiedValues.class);
            return demapifier.invoke(null, values);
         } catch (InvocationTargetException demapifyException) {
            return tryFallbackToLegacyDeserialize(clazz, values).orElseThrow(() -> new FailureException(demapifyException.getCause()));
         } catch (IllegalAccessException | NoSuchMethodException demapifyException) {
            throw new FailureException(demapifyException);
         }
      };
   }

   private static Optional<Object> tryFallbackToLegacyDeserialize(Class<?> clazz, MapifiedValues values) {
      try {
         Method deserialize = clazz.getMethod("deserializeSessionAttribute", Map.class);
         LOG.debug("Falling back to demapification using legacy deserializeSessionAttribute method.");
         return Optional.of(deserialize.invoke(null, values.asMap()));
      } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
         return Optional.empty();
      }
   }

   private Object restoreFromHttpSession(String ref) {
      Object restored = ((HttpSession)this.httpSession.get()).getAttribute(ref);
      if (restored == null) {
         if (((HttpSession)this.httpSession.get()).isNew()) {
            throw new FailureException("Cached object was not found in newly created HTTP Session (probable reason: JSESSIONID cookie not present): " + ref);
         } else {
            throw new FailureException("Expected cached object in HTTP session but it was not found: " + ref);
         }
      } else {
         ((HttpSession)this.httpSession.get()).removeAttribute(ref);
         return restored;
      }
   }

   private MapifiedValues cacheInHttpSession(Object object) {
      logWarningOnceForUnsupportedType(object.getClass());
      String ref = "ref-" + object.getClass().getSimpleName() + COUNTER.getAndIncrement();
      ((HttpSession)this.httpSession.get()).setAttribute(ref, object);
      return new MapifiedValues(Map.of("ref", ref));
   }

   private static void logWarningOnceForUnsupportedType(Class<?> clazz) {
      if (IamSystemProperties.isStrictMode()) {
         throw new FailureException("We don't want to cache objects in HTTP session: " + clazz.getSimpleName());
      }

      if (NON_MAPIFABLE_CLASSES.add(clazz)) {
         LOG.warn(
            EDefaultLogType.GENERIC_LOG_TYPE,
            "Type "
               + clazz.getName()
               + " does not implement Mapifiable and thus is not usable in setups with externally managed state (Redis). Updating of custom code is strongly advised as this could lead to loss of sessions and data."
         );
      }
   }

   private static <T> T enumValue(Class<?> clazz, String value) {
      try {
         Method valueOf = clazz.getMethod("valueOf", String.class);
         return (T)valueOf.invoke(null, value);
      } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
         throw new FailureException(e);
      }
   }
}
