import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { IdempotencyRepository } from './idempotency.repository';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import InifniConflictException from 'src/common/exceptions/infini-conflict-exception';

type ClaimResult =
  | { outcome: 'claimed'; id: number }
  | { outcome: 'replay'; data: object | null }
  | { outcome: 'conflict'; reason: 'hash_mismatch' | 'in_progress' };

@Injectable()
export class IdempotencyService {
  constructor(
    private readonly idempotencyRepository: IdempotencyRepository,
    private readonly logger: AppLoggerService,
  ) {}

  // Runs `executor` under an Idempotency-Key guard. Callers that don't pass a key
  // (older/unmigrated clients) get today's behaviour unchanged — the guard is opt-in per
  // request, not enforced per endpoint, so it can roll out without breaking existing clients.
  async run<T>(
    idempotencyKey: string | undefined | null,
    endpoint: string,
    payload: unknown,
    userId: number | null,
    executor: () => Promise<T>,
  ): Promise<T> {
    if (!idempotencyKey) {
      return executor();
    }

    const requestHash = this.hashPayload(payload);
    const claim = await this.claim(idempotencyKey, endpoint, requestHash, userId);

    if (claim.outcome === 'replay') {
      this.logger.log('Idempotency: replaying cached response', { endpoint, idempotencyKey });
      return claim.data as T;
    }

    if (claim.outcome === 'conflict') {
      const code =
        claim.reason === 'hash_mismatch' ? ERROR_CODES.IDEMPOTENCY_KEY_CONFLICT : ERROR_CODES.IDEMPOTENCY_KEY_IN_PROGRESS;
      throw new InifniConflictException(code, null, null, idempotencyKey);
    }

    try {
      const result = await executor();
      await this.idempotencyRepository.markCompleted(claim.id, (result ?? null) as object | null);
      return result;
    } catch (error) {
      await this.idempotencyRepository.markFailed(claim.id);
      throw error;
    }
  }

  private async claim(
    idempotencyKey: string,
    endpoint: string,
    requestHash: string,
    userId: number | null,
  ): Promise<ClaimResult> {
    const inserted = await this.idempotencyRepository.tryInsertPending(idempotencyKey, endpoint, requestHash, userId);
    if (inserted) {
      return { outcome: 'claimed', id: inserted.id };
    }

    const existing = await this.idempotencyRepository.findByKeyAndEndpoint(idempotencyKey, endpoint, userId);
    if (!existing) {
      // Lost the insert race but the winner's row isn't visible yet (extremely narrow window) —
      // treat as in-progress rather than retrying in a loop.
      return { outcome: 'conflict', reason: 'in_progress' };
    }

    // Every request that finds an existing row is, by definition, at least the second time
    // this key/endpoint/user has been seen — count it regardless of the outcome below.
    await this.idempotencyRepository.incrementHitCount(existing.id);

    // A prior attempt under this key never succeeded, so nothing has been done yet on the
    // server's behalf — the caller is free to retry with a corrected payload, not just the
    // exact same one. Check this before the hash comparison below: a failed attempt's hash
    // is irrelevant to whether a retry should be allowed.
    if (existing.status === 'failed') {
      const reclaimed = await this.idempotencyRepository.tryReclaimFailed(idempotencyKey, endpoint, requestHash, userId);
      if (reclaimed) {
        return { outcome: 'claimed', id: reclaimed.id };
      }
      // Another concurrent retry won the reclaim first.
      return { outcome: 'conflict', reason: 'in_progress' };
    }

    if (existing.requestHash !== requestHash) {
      return { outcome: 'conflict', reason: 'hash_mismatch' };
    }

    if (existing.status === 'completed') {
      return { outcome: 'replay', data: existing.responseData };
    }

    return { outcome: 'conflict', reason: 'in_progress' };
  }

  private hashPayload(payload: unknown): string {
    return crypto.createHash('sha256').update(JSON.stringify(payload ?? {})).digest('hex');
  }
}
