package com.airlock.iam.login.infrastructure.geolocation;

import com.airlock.iam.common.api.domain.model.geolocation.Geolocation;
import com.airlock.iam.common.api.domain.model.request.IpAddress;
import com.airlock.iam.common.domain.model.geolocation.CoordinatesImpl;
import com.airlock.iam.common.domain.model.geolocation.GeolocationImpl;
import com.airlock.iam.common.domain.model.geolocation.GeolocationProvider;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.plugin.api.config.LogTypes;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.maxmind.db.Reader.FileMode;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.DatabaseReader.Builder;
import com.maxmind.geoip2.exception.AddressNotFoundException;
import com.maxmind.geoip2.exception.GeoIp2Exception;
import com.maxmind.geoip2.model.CityResponse;
import com.maxmind.geoip2.model.CountryResponse;
import com.maxmind.geoip2.record.City;
import com.maxmind.geoip2.record.Continent;
import com.maxmind.geoip2.record.Country;
import com.maxmind.geoip2.record.Location;
import com.maxmind.geoip2.record.Postal;
import com.maxmind.geoip2.record.Subdivision;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.InetAddress;
import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;

public class MaxMindGeolocationProvider implements GeolocationProvider, Closeable {
   private static final ELogger LOG = ELoggerFactory.getLogger(MaxMindGeolocationProvider.class);
   private static final Set<String> KNOWN_TIMEZONES = Sets.newHashSet(Arrays.asList(TimeZone.getAvailableIDs()));
   private final MaxMindGeolocationProvider.DbConnectionProvider dbConnection;

   public MaxMindGeolocationProvider(File dbFileLocation) {
      this.dbConnection = new MaxMindGeolocationProvider.DbConnectionProvider(dbFileLocation);
   }

   @Override
   public Optional<Geolocation> determineGeolocation(IpAddress ip) {
      MaxMindGeolocationProvider.MaxMindDbType dbType = this.dbConnection.get().getDbType();

      return switch (dbType) {
         case CITY -> this.readCityDb(ip);
         case COUNTRY -> this.readCountryDb(ip);
      };
   }

   private Optional<Geolocation> readCityDb(IpAddress ip) {
      CityResponse cityResponse;
      try {
         cityResponse = this.dbConnection.get().readCity(ip);
         if (cityResponse == null) {
            LOG.debug("Geolocation of IP yielded no result: \"" + ip.value() + "\"");
            return Optional.empty();
         }
      } catch (AddressNotFoundException e) {
         LOG.debug("Geolocation of IP \"" + ip.value() + "\" yielded no result.");
         return Optional.empty();
      } catch (IOException | GeoIp2Exception e) {
         LOG.warn(LogTypes.PLUGIN_INFO, "Geolocation retrieval failed: " + e.getMessage(), e);
         return Optional.empty();
      }

      GeolocationImpl.GeolocationImplBuilder builder = GeolocationImpl.builder();
      Country country = cityResponse.getCountry();
      Continent continent = cityResponse.getContinent();
      this.extractCountryAndContinent(builder, country, continent);
      Subdivision subdivision = cityResponse.getMostSpecificSubdivision();
      if (subdivision != null) {
         builder.subdivisionCode(subdivision.getIsoCode());
      }

      City city = cityResponse.getCity();
      if (city != null) {
         builder.city(city.getName());
      }

      Postal postal = cityResponse.getPostal();
      if (postal != null) {
         builder.zip(postal.getCode());
      }

      Location location = cityResponse.getLocation();
      if (location != null) {
         String timeZoneString = location.getTimeZone();
         if (timeZoneString != null) {
            if (KNOWN_TIMEZONES.contains(timeZoneString)) {
               builder.timeZoneId(timeZoneString);
            } else {
               LOG.info(LogTypes.PLUGIN_INFO, "Ignoring unknown TimeZone from Geolocation lookup: \"" + timeZoneString + "\"");
            }
         }

         if (location.getLatitude() != null && location.getLongitude() != null) {
            builder.coordinates(new CoordinatesImpl(location.getLatitude(), location.getLongitude()));
         }
      }

      return Optional.of(builder.build());
   }

   private Optional<Geolocation> readCountryDb(IpAddress ip) {
      CountryResponse countryResponse;
      try {
         countryResponse = this.dbConnection.get().readCountry(ip);
         if (countryResponse == null) {
            LOG.debug("Geolocation of IP yielded no result: \"" + ip.value() + "\"");
            return Optional.empty();
         }
      } catch (AddressNotFoundException e) {
         LOG.debug("Geolocation of IP \"" + ip.value() + "\" yielded no result.");
         return Optional.empty();
      } catch (IOException | GeoIp2Exception e) {
         LOG.warn(LogTypes.PLUGIN_INFO, "Geolocation retrieval failed: " + e.getMessage(), e);
         return Optional.empty();
      }

      GeolocationImpl.GeolocationImplBuilder builder = GeolocationImpl.builder();
      Country country = countryResponse.getCountry();
      Continent continent = countryResponse.getContinent();
      this.extractCountryAndContinent(builder, country, continent);
      return Optional.of(builder.build());
   }

   private void extractCountryAndContinent(GeolocationImpl.GeolocationImplBuilder builder, Country country, Continent continent) {
      if (country != null) {
         builder.countryCode(country.getIsoCode());
      }

      if (continent != null) {
         builder.continentCode(continent.getCode());
      }
   }

   @VisibleForTesting
   MaxMindGeolocationProvider.MaxMindDbType getDbType() {
      return this.dbConnection.get().getDbType();
   }

   @Override
   public void close() {
      this.dbConnection.close();
   }

   protected static class DbConnectionProvider implements Closeable {
      private final File dbFile;
      private volatile Optional<MaxMindGeolocationProvider.DbConnectionProvider.DbConnection> dbConnectionInfo = Optional.empty();

      public DbConnectionProvider(File dbFileLocation) {
         this.dbFile = dbFileLocation;

         try {
            this.createNewDbReader();
         } catch (Exception var3) {
         }
      }

      public MaxMindGeolocationProvider.DbConnectionProvider.DbConnection get() throws GeolocationProvider.GeolocationProviderException {
         try {
            this.ensureInitializedAndCurrent();
         } catch (FileNotFoundException e) {
            MaxMindGeolocationProvider.LOG.warn(LogTypes.PLUGIN_INFO, "Unable to initialize MaxMind DB Reader: " + e.getMessage());
            throw new GeolocationProvider.GeolocationProviderException("Unable to initialize MaxMind DB Reader: " + e.getMessage(), e);
         } catch (IOException e) {
            MaxMindGeolocationProvider.LOG.warn(LogTypes.PLUGIN_INFO, "Unable to initialize MaxMind DB Reader: " + e.getMessage(), e);
            throw new GeolocationProvider.GeolocationProviderException("Unable to initialize MaxMind DB Reader: " + e.getMessage(), e);
         }

         return this.dbConnectionInfo.orElseThrow();
      }

      private void ensureInitializedAndCurrent() throws IOException {
         if (this.dbConnectionInfo.isEmpty()) {
            this.createNewDbReader();
         } else if (this.dbConnectionInfo.map(info -> info.hasDbBeenUpdated(this.dbFile)).orElse(false)) {
            MaxMindGeolocationProvider.LOG.info("Reloading MaxMind database: " + this.dbFile.getPath());
            this.createNewDbReader();
         }
      }

      private synchronized void createNewDbReader() throws IOException {
         this.close();
         long dbTimestamp = this.dbFile.lastModified();
         DatabaseReader dbReader = new Builder(this.dbFile).fileMode(FileMode.MEMORY).locales(Lists.newArrayList(new String[]{"en"})).build();
         MaxMindGeolocationProvider.MaxMindDbType dbType = MaxMindGeolocationProvider.MaxMindDbType.getDbTypeByName(dbReader.getMetadata().getDatabaseType());
         this.dbConnectionInfo = Optional.of(new MaxMindGeolocationProvider.DbConnectionProvider.DbConnection(dbReader, dbType, dbTimestamp));
         MaxMindGeolocationProvider.LOG.debug("Successfully read MaxMind DB \"" + this.dbFile.getPath() + "\" of type " + dbType + ".");
      }

      @Override
      public final synchronized void close() {
         this.dbConnectionInfo.ifPresent(info -> {
            try {
               info.getDbReader().close();
               MaxMindGeolocationProvider.LOG.debug("Closed MaxMind DB reader on file " + this.dbFile.getPath());
            } catch (IOException e) {
               MaxMindGeolocationProvider.LOG.info("Unable to close MaxMind Geolocation DB reader: " + e.getMessage());
            }
         });
         this.dbConnectionInfo = Optional.empty();
      }

      protected static final class DbConnection {
         private final DatabaseReader dbReader;
         private final MaxMindGeolocationProvider.MaxMindDbType dbType;
         private final long dbTimestamp;

         private boolean hasDbBeenUpdated(File dbFile) {
            return dbFile.exists() && dbFile.lastModified() != this.dbTimestamp;
         }

         private CityResponse readCity(IpAddress ip) throws IOException, GeoIp2Exception {
            return this.dbReader.city(InetAddress.getByAddress(ip.getAddress()));
         }

         private CountryResponse readCountry(IpAddress ip) throws IOException, GeoIp2Exception {
            return this.dbReader.country(InetAddress.getByAddress(ip.getAddress()));
         }

         @SuppressFBWarnings(justification = "generated code")
         public DbConnection(DatabaseReader dbReader, MaxMindGeolocationProvider.MaxMindDbType dbType, long dbTimestamp) {
            this.dbReader = dbReader;
            this.dbType = dbType;
            this.dbTimestamp = dbTimestamp;
         }

         @SuppressFBWarnings(justification = "generated code")
         public DatabaseReader getDbReader() {
            return this.dbReader;
         }

         @SuppressFBWarnings(justification = "generated code")
         public MaxMindGeolocationProvider.MaxMindDbType getDbType() {
            return this.dbType;
         }

         @SuppressFBWarnings(justification = "generated code")
         public long getDbTimestamp() {
            return this.dbTimestamp;
         }

         @SuppressFBWarnings(justification = "generated code")
         @Override
         public boolean equals(Object o) {
            if (o == this) {
               return true;
            } else if (!(o instanceof MaxMindGeolocationProvider.DbConnectionProvider.DbConnection other)) {
               return false;
            } else if (this.dbTimestamp != other.dbTimestamp) {
               return false;
            } else {
               Object this$dbReader = this.dbReader;
               Object other$dbReader = other.dbReader;
               if (this$dbReader == null ? other$dbReader == null : this$dbReader.equals(other$dbReader)) {
                  Object this$dbType = this.dbType;
                  Object other$dbType = other.dbType;
                  return this$dbType == null ? other$dbType == null : this$dbType.equals(other$dbType);
               } else {
                  return false;
               }
            }
         }

         @SuppressFBWarnings(justification = "generated code")
         @Override
         public int hashCode() {
            int PRIME = 59;
            int result = 1;
            long $dbTimestamp = this.dbTimestamp;
            result = result * 59 + (int)($dbTimestamp >>> 32 ^ $dbTimestamp);
            Object $dbReader = this.dbReader;
            result = result * 59 + ($dbReader == null ? 43 : $dbReader.hashCode());
            Object $dbType = this.dbType;
            return result * 59 + ($dbType == null ? 43 : $dbType.hashCode());
         }

         @SuppressFBWarnings(justification = "generated code")
         @Override
         public String toString() {
            return "MaxMindGeolocationProvider.DbConnectionProvider.DbConnection(dbReader="
               + this.getDbReader()
               + ", dbType="
               + this.getDbType()
               + ", dbTimestamp="
               + this.getDbTimestamp()
               + ")";
         }
      }
   }

   @VisibleForTesting
   protected enum MaxMindDbType {
      CITY("GeoLite2-City"),
      COUNTRY("GeoLite2-Country");

      private final String identifier;

      MaxMindDbType(String identifier) {
         this.identifier = identifier;
      }

      public static MaxMindGeolocationProvider.MaxMindDbType getDbTypeByName(String name) {
         for (MaxMindGeolocationProvider.MaxMindDbType type : values()) {
            if (type.identifier.equals(name)) {
               return type;
            }
         }

         throw new IllegalArgumentException("Unknown DB Type: \"" + name + "\"");
      }
   }
}
