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

import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.misc.exception.Exceptions;
import com.airlock.iam.common.domain.model.accountlink.AccountLinkDataToPersist;
import com.airlock.iam.common.domain.model.accountlink.AccountLinkId;
import com.airlock.iam.common.domain.model.accountlink.AccountSub;
import com.airlock.iam.common.domain.model.accountlink.persistence.AccountLinkData;
import com.airlock.iam.common.domain.model.accountlink.persistence.AccountLinkUsernameChangeData;
import com.airlock.iam.common.domain.model.provider.ProviderId;
import com.airlock.iam.common.domain.service.accountlink.persistence.AccountLinkPersister;
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.AccountLinkTable;
import com.airlock.iam.database.infrastructure.generated.schema.tables.records.AccountLinkRecord;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Optional;
import java.util.function.Supplier;
import org.jooq.Condition;
import org.jooq.DSLContext;
import org.jooq.InsertSetMoreStep;
import org.jooq.InsertSetStep;
import org.jooq.Result;
import org.jooq.Sequence;
import org.jooq.impl.DSL;
import org.jooq.impl.SQLDataType;

public final class AccountLinkDatabasePersisterImpl implements AccountLinkPersister {
   private static final AccountLinkRecordMapper MAPPER = new AccountLinkRecordMapper();
   private final Supplier<DSLContext> provider;
   private final Optional<Sequence<Integer>> sequence;
   private final TenantId tenantId;

   public AccountLinkDatabasePersisterImpl(JooqConfig jooqConfig) {
      this.provider = () -> JooqProvider.jooqProvider(jooqConfig).db();
      this.sequence = jooqConfig.sequenceName().map(s -> DSL.sequence(DSL.name(s), SQLDataType.INTEGER));
      this.tenantId = jooqConfig.tenantId();
   }

   @VisibleForTesting
   AccountLinkDatabasePersisterImpl(DSLContext db) {
      this.provider = () -> db;
      this.sequence = Optional.empty();
      this.tenantId = TenantId.tenantId("no_tenant");
   }

   @Override
   public AccountLinkData persistAndLoad(AccountLinkDataToPersist data) {
      return Exceptions.wrapException(
         () -> MAPPER.map(
            (AccountLinkRecord)JooqTransaction.jooqTransaction(this.db())
               .executeAndReturn(db -> (AccountLinkRecord)this.insert(db, data).returning().fetchOne())
         ),
         e -> new FailureException("Could not persist and load " + data, e)
      );
   }

   @Override
   public void persist(AccountLinkDataToPersist data) {
      try {
         JooqTransaction.jooqTransaction(this.db()).execute(db -> this.insert(db, data).execute());
      } catch (Exception e) {
         throw new FailureException("Could not persist " + data, e);
      }
   }

   @Override
   public Optional<AccountLinkData> findUnique(ProviderId providerId, AccountSub accountSub) {
      return Exceptions.wrapException(
         () -> this.find(providerId, accountSub).map(MAPPER::map),
         e -> new FailureException("Could not find unique account link for " + accountSub + " and " + providerId, e)
      );
   }

   @Override
   public Collection<AccountLinkData> findByUser(String username) {
      return Exceptions.wrapException(
         () -> this.db()
            .selectFrom(AccountLinkTable.ACCOUNT_LINK)
            .where(usernameMatches(username))
            .and(this.accountLinkTenantMatches())
            .orderBy(AccountLinkTable.ACCOUNT_LINK.PROVIDER_ID.asc())
            .fetch()
            .map(MAPPER),
         e -> new FailureException("Could not find the account links of the user '" + username + "'", e)
      );
   }

   @Override
   public int deleteByUser(String username) {
      return Exceptions.wrapException(
         () -> (Integer)JooqTransaction.jooqTransaction(this.db()).executeAndReturn(db -> this.deleteByUser(db, username)),
         e -> new FailureException("Could not delete the account links of the user '" + username + "'", e)
      );
   }

   @Override
   public int delete(AccountLinkId id) {
      return Exceptions.wrapException(
         () -> (Integer)JooqTransaction.jooqTransaction(this.db()).executeAndReturn(db -> this.delete(db, id)),
         e -> new FailureException("Could not delete the account link with id " + id, e)
      );
   }

   @Override
   public int updateUsername(AccountLinkUsernameChangeData accountLinkUsernameChangeData) {
      return Exceptions.wrapException(
         () -> (Integer)JooqTransaction.jooqTransaction(this.db()).executeAndReturn(db -> this.updateUsername(db, accountLinkUsernameChangeData)),
         e -> new FailureException("Could not update the username " + accountLinkUsernameChangeData, e)
      );
   }

   @Override
   public boolean hasLinkForProvider(String username, ProviderId providerId) {
      return Exceptions.wrapException(
         () -> this.db()
            .selectOne()
            .from(AccountLinkTable.ACCOUNT_LINK)
            .where(AccountLinkTable.ACCOUNT_LINK.USERNAME.eq(username))
            .and(AccountLinkTable.ACCOUNT_LINK.PROVIDER_ID.eq(providerId.id()))
            .and(this.accountLinkTenantMatches())
            .fetch()
            .isNotEmpty(),
         e -> new FailureException("Could not check if the user '" + username + "' has a link for '" + providerId + "'", e)
      );
   }

   private Optional<AccountLinkRecord> find(ProviderId providerId, AccountSub accountSub) {
      Result<AccountLinkRecord> result = this.db()
         .selectFrom(AccountLinkTable.ACCOUNT_LINK)
         .where(AccountLinkTable.ACCOUNT_LINK.PROVIDER_ID.eq(providerId.id()))
         .and(AccountLinkTable.ACCOUNT_LINK.ACCOUNT_SUB.eq(accountSub.sub()))
         .and(this.accountLinkTenantMatches())
         .fetch();
      return this.checkOnlyOneResult(result, accountSub, providerId).stream().findFirst();
   }

   private Result<AccountLinkRecord> checkOnlyOneResult(Result<AccountLinkRecord> result, AccountSub accountSub, ProviderId providerId) {
      int resultSize = result.size();
      Preconditions.checkState(
         resultSize <= 1,
         "Expected only one link for provider id '"
            + providerId
            + "', accountSub '"
            + accountSub
            + "' and tenant id '"
            + this.tenantId.tenantId()
            + "' but got "
            + resultSize
      );
      return result;
   }

   private int updateUsername(DSLContext db, AccountLinkUsernameChangeData changeData) {
      return db.update(AccountLinkTable.ACCOUNT_LINK)
         .set(AccountLinkTable.ACCOUNT_LINK.USERNAME, changeData.newUsername())
         .where(usernameMatches(changeData.oldUsername()))
         .and(this.accountLinkTenantMatches())
         .execute();
   }

   private int delete(DSLContext db, AccountLinkId id) {
      return db.deleteFrom(AccountLinkTable.ACCOUNT_LINK)
         .where(AccountLinkTable.ACCOUNT_LINK.LINK_ID.eq(id.id()))
         .and(this.accountLinkTenantMatches())
         .execute();
   }

   private InsertSetMoreStep<AccountLinkRecord> insert(DSLContext db, AccountLinkDataToPersist data) {
      return this.startInsertStatement(db, data.username())
         .set(AccountLinkTable.ACCOUNT_LINK.PROVIDER_ID, data.providerId())
         .set(AccountLinkTable.ACCOUNT_LINK.ACCOUNT_SUB, data.accountSub())
         .set(AccountLinkTable.ACCOUNT_LINK.ACCOUNT_INFO, data.accountInfo())
         .set(AccountLinkTable.ACCOUNT_LINK.ESTABLISHED_AT, LocalDateTime.now())
         .set(AccountLinkTable.ACCOUNT_LINK.TENANT_ID, this.tenantId.tenantId());
   }

   private InsertSetMoreStep<AccountLinkRecord> startInsertStatement(DSLContext db, String username) {
      return this.sequence
         .<InsertSetMoreStep<AccountLinkRecord>>map(s -> this.insertWithSequence(db, (Sequence<Integer>)s, username))
         .orElse(this.insertWithoutSequence(db, username));
   }

   private InsertSetMoreStep<AccountLinkRecord> insertWithoutSequence(DSLContext db, String username) {
      return this.beginInsert(db).set(AccountLinkTable.ACCOUNT_LINK.USERNAME, username);
   }

   private InsertSetMoreStep<AccountLinkRecord> insertWithSequence(DSLContext db, Sequence<Integer> seq, String username) {
      return this.beginInsert(db).set(AccountLinkTable.ACCOUNT_LINK.LINK_ID, seq.nextval()).set(AccountLinkTable.ACCOUNT_LINK.USERNAME, username);
   }

   private InsertSetStep<AccountLinkRecord> beginInsert(DSLContext db) {
      return db.insertInto(AccountLinkTable.ACCOUNT_LINK);
   }

   private int deleteByUser(DSLContext db, String username) {
      return db.deleteFrom(AccountLinkTable.ACCOUNT_LINK).where(usernameMatches(username)).and(this.accountLinkTenantMatches()).execute();
   }

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

   private Condition accountLinkTenantMatches() {
      return AccountLinkTable.ACCOUNT_LINK.TENANT_ID.eq(this.tenantId.tenantId());
   }

   private static Condition usernameMatches(String username) {
      return AccountLinkTable.ACCOUNT_LINK.USERNAME.eq(username);
   }
}
