package com.airlock.iam.flow.shared.infrastructure.rememberme;

import com.airlock.iam.base.api.domain.model.exception.EntityNotFoundException;
import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.api.domain.model.user.UserId;
import com.airlock.iam.base.infrastructure.db.DbDoubleConverter;
import com.airlock.iam.base.infrastructure.db.DbInstantConverter;
import com.airlock.iam.base.infrastructure.db.DbUtils;
import com.airlock.iam.base.misc.exception.Exceptions;
import com.airlock.iam.base.misc.time.TimeProvider;
import com.airlock.iam.common.api.domain.model.geolocation.Coordinates;
import com.airlock.iam.common.api.domain.model.geolocation.Geolocation;
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.request.IpAddressImpl;
import com.airlock.iam.common.domain.model.request.UserAgentRequestHeader;
import com.airlock.iam.core.infrastructure.db.JooqProvider;
import com.airlock.iam.database.infrastructure.JooqConfig;
import com.airlock.iam.database.infrastructure.JooqTransaction;
import com.airlock.iam.database.infrastructure.TenantId;
import com.airlock.iam.database.infrastructure.generated.schema.Tables;
import com.airlock.iam.database.infrastructure.generated.schema.tables.RememberMeRequestMetadataTable;
import com.airlock.iam.database.infrastructure.generated.schema.tables.RememberMeTokenTable;
import com.airlock.iam.database.infrastructure.generated.schema.tables.records.RememberMeRequestMetadataRecord;
import com.airlock.iam.database.infrastructure.generated.schema.tables.records.RememberMeTokenRecord;
import com.airlock.iam.flow.shared.domain.model.rememberme.RememberMeRequestMetadata;
import com.airlock.iam.flow.shared.domain.model.rememberme.RememberMeSecretHash;
import com.airlock.iam.flow.shared.domain.model.rememberme.RememberMeToken;
import com.airlock.iam.flow.shared.domain.model.rememberme.RememberMeTokenId;
import com.airlock.iam.flow.shared.domain.model.rememberme.RememberMeTokenRepository;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.annotation.Nonnull;
import jakarta.inject.Inject;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalDouble;
import java.util.TimeZone;
import java.util.function.Supplier;
import lombok.NonNull;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.jooq.Condition;
import org.jooq.DSLContext;
import org.jooq.DeleteConditionStep;
import org.jooq.InsertResultStep;
import org.jooq.InsertSetMoreStep;
import org.jooq.Record;
import org.jooq.Record1;
import org.jooq.SelectConditionStep;
import org.jooq.SelectFieldOrAsterisk;
import org.jooq.SelectSeekStep1;
import org.jooq.Sequence;
import org.jooq.UpdateConditionStep;
import org.jooq.impl.DSL;
import org.jooq.impl.SQLDataType;

public final class RememberMeTokenRepositoryImpl implements RememberMeTokenRepository {
   private static final RememberMeRequestMetadataTable CREATION_REQUEST_TABLE = Tables.REMEMBER_ME_REQUEST_METADATA.as("creationRequest");
   private static final RememberMeRequestMetadataTable RENEWAL_REQUEST_TABLE = Tables.REMEMBER_ME_REQUEST_METADATA.as("renewalRequest");
   @NonNull
   private final Supplier<DSLContext> jooqDslProvider;
   @NonNull
   private final TenantId tenantId;
   private final Optional<Sequence<Long>> requestMetadataSequence;
   private final Optional<Sequence<Long>> tokenSequence;
   private final TimeProvider time;

   @Inject
   public RememberMeTokenRepositoryImpl(@NonNull JooqConfig jooqConfig, @NonNull TimeProvider time) {
      if (jooqConfig == null) {
         throw new NullPointerException("jooqConfig is marked non-null but is null");
      }

      if (time == null) {
         throw new NullPointerException("time is marked non-null but is null");
      }

      this.jooqDslProvider = () -> JooqProvider.jooqProvider(jooqConfig).db();
      this.tenantId = jooqConfig.tenantId();
      this.time = time;
      if (jooqConfig.isOracleDatabase()) {
         this.requestMetadataSequence = Optional.of(DSL.sequence(DSL.name("remember_me_request_metadata_seq"), SQLDataType.BIGINT));
         this.tokenSequence = Optional.of(DSL.sequence(DSL.name("remember_me_token_seq"), SQLDataType.BIGINT));
      } else {
         this.requestMetadataSequence = Optional.empty();
         this.tokenSequence = Optional.empty();
      }
   }

   @Override
   public void insert(RememberMeToken token) {
      Validate.isTrue(token.latestRequest().isEmpty(), "no latest request metadata expected upon insertion of a fresh Remember-Me token", new Object[0]);

      try {
         JooqTransaction.jooqTransaction(this.db()).execute(db -> this.insertToken(token, db));
      } catch (Exception e) {
         throw new FailureException(String.format("Error inserting %s for tenant '%s'", token, this.tenantId), e);
      }
   }

   @SuppressFBWarnings(
      value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
      justification = "requireNonNull is not detected: https://github.com/spotbugs/spotbugs/issues/651"
   )
   private void insertToken(RememberMeToken token, DSLContext db) {
      Long tokenPk = (Long)((Record1)Objects.requireNonNull((Record1)this.insertRememberMeToken(db, token).fetchOne()))
         .get(RememberMeTokenTable.REMEMBER_ME_TOKEN.PK);
      this.insertRequestMetadata(db, token.firstRequest(), tokenPk, RememberMeRequestMetadataType.CREATE_REQUEST).execute();
   }

   private InsertResultStep<Record1<Long>> insertRequestMetadata(
      DSLContext db, RememberMeRequestMetadata metadata, Long rememberMeTokenPk, RememberMeRequestMetadataType requestType
   ) {
      InsertSetMoreStep<RememberMeRequestMetadataRecord> record = db.insertInto(Tables.REMEMBER_ME_REQUEST_METADATA)
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.REMEMBER_ME_TOKEN_PK, rememberMeTokenPk)
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.REQUEST_TYPE, requestType.getDbValue())
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.REQUEST_IP, metadata.requestIpAddress().value())
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.HEADER_USER_AGENT, metadata.userAgentHeader().map(UserAgentRequestHeader::value).orElse(null))
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_CONTINENT, metadata.geolocation().flatMap(Geolocation::getContinentCode).orElse(null))
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_COUNTRY, metadata.geolocation().flatMap(Geolocation::getCountryCode).orElse(null))
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_SUBDIVISION, metadata.geolocation().flatMap(Geolocation::getSubdivisionCode).orElse(null))
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_CITY, metadata.geolocation().flatMap(Geolocation::getCity).orElse(null))
         .set(Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_ZIP, metadata.geolocation().flatMap(Geolocation::getZip).orElse(null))
         .set(
            Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_TIMEZONE,
            metadata.geolocation().flatMap(Geolocation::getTimeZone).map(TimeZone::getID).orElse(null)
         )
         .set(
            Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_LATITUDE,
            metadata.geolocation().flatMap(Geolocation::getCoordinates).map(Coordinates::getLatitude).map(DbDoubleConverter::toString).orElse(null)
         )
         .set(
            Tables.REMEMBER_ME_REQUEST_METADATA.GEOLOCATION_LONGITUDE,
            metadata.geolocation().flatMap(Geolocation::getCoordinates).map(Coordinates::getLongitude).map(DbDoubleConverter::toString).orElse(null)
         );
      if (this.requestMetadataSequence.isPresent()) {
         record = record.set(Tables.REMEMBER_ME_REQUEST_METADATA.PK, this.requestMetadataSequence.get().nextval());
      }

      return record.returningResult(Tables.REMEMBER_ME_REQUEST_METADATA.PK);
   }

   private InsertResultStep<Record1<Long>> insertRememberMeToken(DSLContext db, RememberMeToken token) {
      InsertSetMoreStep<RememberMeTokenRecord> record = db.insertInto(RememberMeTokenTable.REMEMBER_ME_TOKEN)
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.ID, token.getId().asString())
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.CREATED_AT, DbInstantConverter.toNanoSecondsSinceEpoch(token.firstUsedAt()))
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.IAM_USER_ID, token.iamUserId().getValue())
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.TOKEN_HASH, token.secretHash().bytes())
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.TENANT_ID, this.tenantId.tenantId());
      if (this.tokenSequence.isPresent()) {
         record = record.set(RememberMeTokenTable.REMEMBER_ME_TOKEN.PK, this.tokenSequence.get().nextval());
      }

      return record.returningResult(RememberMeTokenTable.REMEMBER_ME_TOKEN.PK);
   }

   @Override
   public Optional<RememberMeToken> findBy(RememberMeSecretHash hash) {
      return Exceptions.wrapException(
         () -> this.findRememberMeTokens(this.tokenHashMatches(hash)).fetchOptional(this::mapRememberMeToken),
         e -> new FailureException(String.format("Error finding Remember-Me token with hash '%s' for tenant '%s'", hash, this.tenantId), e)
      );
   }

   @Override
   public List<RememberMeToken> findBy(UserId userId) {
      return Exceptions.wrapException(
         () -> this.findRememberMeTokens(this.userIdMatches(userId)).fetch(this::mapRememberMeToken),
         e -> new FailureException(String.format("Error finding Remember-Me token for user '%s' and tenant '%s'", userId.getValue(), this.tenantId), e)
      );
   }

   @Override
   public Optional<RememberMeToken> findBy(RememberMeTokenId tokenId) {
      return Exceptions.wrapException(
         () -> this.findRememberMeTokens(this.tokenIdMatches(tokenId)).fetchOptional(this::mapRememberMeToken),
         e -> new FailureException(String.format("Error finding Remember-Me token for ID '%s' and tenant '%s'", tokenId.asString(), this.tenantId), e)
      );
   }

   private SelectSeekStep1<Record, Long> findRememberMeTokens(Condition whereClause) {
      return this.db()
         .select(new SelectFieldOrAsterisk[0])
         .from(RememberMeTokenTable.REMEMBER_ME_TOKEN)
         .join(CREATION_REQUEST_TABLE)
         .on(
            CREATION_REQUEST_TABLE.REMEMBER_ME_TOKEN_PK
               .eq(RememberMeTokenTable.REMEMBER_ME_TOKEN.PK)
               .and(requestTypeMatches(RememberMeRequestMetadataType.CREATE_REQUEST, CREATION_REQUEST_TABLE))
         )
         .leftJoin(RENEWAL_REQUEST_TABLE)
         .on(
            RENEWAL_REQUEST_TABLE.REMEMBER_ME_TOKEN_PK
               .eq(RememberMeTokenTable.REMEMBER_ME_TOKEN.PK)
               .and(requestTypeMatches(RememberMeRequestMetadataType.RENEW_REQUEST, RENEWAL_REQUEST_TABLE))
         )
         .where(whereClause)
         .and(this.tenantMatches())
         .orderBy(RememberMeTokenTable.REMEMBER_ME_TOKEN.CREATED_AT.desc());
   }

   private RememberMeToken mapRememberMeToken(Record record) {
      Long renewedAtRaw = (Long)record.get(RememberMeTokenTable.REMEMBER_ME_TOKEN.RENEWED_AT);
      Instant renewedAt = renewedAtRaw != null ? DbInstantConverter.toInstant(renewedAtRaw) : null;
      RememberMeToken.RememberMeTokenBuilder tokenBuilder = RememberMeToken.builder()
         .id(RememberMeTokenId.rememberMeTokenId((String)record.get(RememberMeTokenTable.REMEMBER_ME_TOKEN.ID)))
         .iamUserId(UserId.userId((String)record.get(RememberMeTokenTable.REMEMBER_ME_TOKEN.IAM_USER_ID)))
         .secretHash(RememberMeSecretHash.rememberMeTokenHash((byte[])record.get(RememberMeTokenTable.REMEMBER_ME_TOKEN.TOKEN_HASH)))
         .firstUsedAt(DbInstantConverter.toInstant((Long)record.get(RememberMeTokenTable.REMEMBER_ME_TOKEN.CREATED_AT)))
         .lastUsedAt(renewedAt)
         .firstRequest(mapRequestMetadata(record, CREATION_REQUEST_TABLE))
         .time(this.time);
      if (record.get(RENEWAL_REQUEST_TABLE.PK) != null) {
         tokenBuilder.latestRequest(mapRequestMetadata(record, RENEWAL_REQUEST_TABLE));
      }

      return tokenBuilder.build();
   }

   private static RememberMeRequestMetadata mapRequestMetadata(Record record, RememberMeRequestMetadataTable table) {
      return RememberMeRequestMetadata.builder()
         .requestIpAddress(IpAddressImpl.ipAddress((String)record.get(table.REQUEST_IP)))
         .userAgentHeader(mapUserAgent(record, table).orElse(null))
         .geolocation(mapGeolocation(record, table).orElse(null))
         .build();
   }

   @Nonnull
   private static Optional<UserAgentRequestHeader> mapUserAgent(Record record, RememberMeRequestMetadataTable table) {
      return Optional.ofNullable((String)record.get(table.HEADER_USER_AGENT)).filter(StringUtils::isNotBlank).map(UserAgentRequestHeader::userAgent);
   }

   private static Optional<Geolocation> mapGeolocation(Record record, RememberMeRequestMetadataTable table) {
      String continentCode = (String)record.get(table.GEOLOCATION_CONTINENT);
      String countryCode = (String)record.get(table.GEOLOCATION_COUNTRY);
      String subdivisionCode = (String)record.get(table.GEOLOCATION_SUBDIVISION);
      String city = (String)record.get(table.GEOLOCATION_CITY);
      String zip = (String)record.get(table.GEOLOCATION_ZIP);
      String timeZoneId = (String)record.get(table.GEOLOCATION_TIMEZONE);
      Optional<Coordinates> coordinates = mapCoordinates(record, table);
      boolean allBlank = StringUtils.isBlank(continentCode)
         && StringUtils.isBlank(countryCode)
         && StringUtils.isBlank(subdivisionCode)
         && StringUtils.isBlank(city)
         && StringUtils.isBlank(zip)
         && StringUtils.isBlank(timeZoneId)
         && coordinates.isEmpty();
      return allBlank
         ? Optional.empty()
         : Optional.of(
            GeolocationImpl.builder()
               .continentCode(continentCode)
               .countryCode(countryCode)
               .subdivisionCode(subdivisionCode)
               .city(city)
               .zip(zip)
               .coordinates(coordinates.orElse(null))
               .timeZoneId(timeZoneId)
               .build()
         );
   }

   private static Optional<Coordinates> mapCoordinates(Record record, RememberMeRequestMetadataTable table) {
      OptionalDouble latitute = DbDoubleConverter.parseDouble((String)record.get(table.GEOLOCATION_LATITUDE));
      OptionalDouble longitude = DbDoubleConverter.parseDouble((String)record.get(table.GEOLOCATION_LONGITUDE));
      return !latitute.isEmpty() && !longitude.isEmpty() ? Optional.of(new CoordinatesImpl(latitute.getAsDouble(), longitude.getAsDouble())) : Optional.empty();
   }

   private Condition tenantMatches() {
      return RememberMeTokenTable.REMEMBER_ME_TOKEN.TENANT_ID.eq(this.tenantId.tenantId());
   }

   private Condition tokenHashMatches(RememberMeSecretHash hash) {
      return RememberMeTokenTable.REMEMBER_ME_TOKEN.TOKEN_HASH.eq(hash.bytes());
   }

   private Condition userIdMatches(UserId userId) {
      return RememberMeTokenTable.REMEMBER_ME_TOKEN.IAM_USER_ID.eq(userId.getValue());
   }

   private Condition tokenIdMatches(RememberMeTokenId tokenId) {
      return RememberMeTokenTable.REMEMBER_ME_TOKEN.ID.eq(tokenId.asString());
   }

   private static Condition requestTypeMatches(RememberMeRequestMetadataType type) {
      return Tables.REMEMBER_ME_REQUEST_METADATA.REQUEST_TYPE.eq(type.getDbValue());
   }

   private static Condition requestTypeMatches(RememberMeRequestMetadataType type, RememberMeRequestMetadataTable rememberMeRequestMetadata) {
      return rememberMeRequestMetadata.REQUEST_TYPE.eq(type.getDbValue());
   }

   @Override
   public void update(RememberMeToken token) {
      Validate.isTrue(token.latestRequest().isPresent(), "latest request metadata should be present when updating Remember-Me token", new Object[0]);

      try {
         JooqTransaction.jooqTransaction(this.db())
            .execute(
               db -> {
                  EntityNotFoundException notFoundException = new EntityNotFoundException(
                     String.format("Remember-Me token %s not found for tenant '%s'", token, this.tenantId)
                  );
                  RememberMeTokenRecord oldTokenRecord = (RememberMeTokenRecord)this.findBy(db, token.getId())
                     .fetchOptional()
                     .orElseThrow(() -> notFoundException);
                  Long tokenPk = (Long)oldTokenRecord.get(RememberMeTokenTable.REMEMBER_ME_TOKEN.PK);
                  deleteRequestMetadata(db, tokenPk, RememberMeRequestMetadataType.RENEW_REQUEST).execute();
                  this.insertRequestMetadata(db, token.latestRequest().get(), tokenPk, RememberMeRequestMetadataType.RENEW_REQUEST).execute();
                  DbUtils.assertExactlyOneRecordAffectedOrElseHandleExceptions(this.updateRememberMeToken(db, token).execute(), notFoundException);
               }
            );
      } catch (EntityNotFoundException e) {
         throw e;
      } catch (Exception e) {
         throw new FailureException(String.format("Error while updating %s for tenant '%s'", token, this.tenantId), e);
      }
   }

   private SelectConditionStep<RememberMeTokenRecord> findBy(DSLContext db, RememberMeTokenId id) {
      return db.selectFrom(RememberMeTokenTable.REMEMBER_ME_TOKEN).where(RememberMeTokenTable.REMEMBER_ME_TOKEN.ID.eq(id.asString())).and(this.tenantMatches());
   }

   private UpdateConditionStep<RememberMeTokenRecord> updateRememberMeToken(DSLContext db, RememberMeToken token) {
      Instant lastUsed = token.lastUsedAt().orElseThrow();
      return db.update(RememberMeTokenTable.REMEMBER_ME_TOKEN)
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.TOKEN_HASH, token.secretHash().bytes())
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.CREATED_AT, DbInstantConverter.toNanoSecondsSinceEpoch(token.firstUsedAt()))
         .set(RememberMeTokenTable.REMEMBER_ME_TOKEN.RENEWED_AT, DbInstantConverter.toNanoSecondsSinceEpoch(lastUsed))
         .where(RememberMeTokenTable.REMEMBER_ME_TOKEN.ID.eq(token.getId().asString()))
         .and(this.tenantMatches());
   }

   private static DeleteConditionStep<RememberMeRequestMetadataRecord> deleteRequestMetadata(DSLContext db, Long tokenPk, RememberMeRequestMetadataType type) {
      return db.deleteFrom(Tables.REMEMBER_ME_REQUEST_METADATA)
         .where(Tables.REMEMBER_ME_REQUEST_METADATA.REMEMBER_ME_TOKEN_PK.eq(tokenPk))
         .and(requestTypeMatches(type));
   }

   @Override
   public boolean deleteBy(RememberMeSecretHash hash) {
      return Exceptions.wrapException(
         () -> (Boolean)JooqTransaction.jooqTransaction(this.db())
            .executeAndReturn(db -> this.deleteByHash(db, RememberMeTokenTable.REMEMBER_ME_TOKEN.TOKEN_HASH.eq(hash.bytes()))),
         e -> new FailureException(String.format("Error while deleting Remember-Me token with hash '%s'", hash.toString()), e)
      );
   }

   @Override
   public boolean deleteBy(RememberMeTokenId tokenId) {
      return Exceptions.wrapException(
         () -> (Boolean)JooqTransaction.jooqTransaction(this.db())
            .executeAndReturn(db -> this.deleteByHash(db, RememberMeTokenTable.REMEMBER_ME_TOKEN.ID.eq(tokenId.asString()))),
         e -> new FailureException(String.format("Error while deleting Remember-Me token with ID '%s'", tokenId.asString()), e)
      );
   }

   private boolean deleteByHash(DSLContext db, Condition whereClause) {
      int deletedRecords = db.deleteFrom(RememberMeTokenTable.REMEMBER_ME_TOKEN).where(whereClause).and(this.tenantMatches()).execute();
      if (deletedRecords == 0) {
         return false;
      } else if (deletedRecords == 1) {
         return true;
      } else {
         throw new FailureException(String.format("Unexpected number of affected records while deleting Remember-Me token: %d", deletedRecords));
      }
   }

   private DSLContext db() {
      return this.jooqDslProvider.get();
   }
}
