import crypto from 'node:crypto'
import { getDb } from '../db/client.js'
import { getOtpRateLimits } from './config.service.js'
import { OtpChannel, UserType } from '../constants.js'

const sql = getDb()

const OTP_TTL_MINUTES = 5

// -------------------------------------------------------------------
// ⚠ Fazpass integration — STUB until real API docs are obtained.
//
// In production, Fazpass is the source of truth for the OTP code.
// We will only ever handle a reference ID (string) returned by Fazpass,
// never the raw code. For now, we generate a 6-digit code locally and
// store its bcrypt hash in the metadata field of otp_requests via
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
// round-trip without schema changes.
//
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
// with real HTTP calls and drop the local code generation.
// -------------------------------------------------------------------

const generate6DigitCode = () => {
  // Avoid Math.random for OTP generation — use crypto.randomInt
  return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
}

const fazpassSendStub = async ({ phone, channel }) => {
  const reference = `stub_${crypto.randomUUID()}`
  const code = generate6DigitCode()
  // Log the code so developers can read it during dev testing.
  // eslint-disable-next-line no-console
  console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
  return { reference, channel_used: channel, code } // `code` only present in stub
}

const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
  return { valid: code === expectedCode }
}
// -------------------------------------------------------------------

export class OtpError extends Error {
  constructor(message, code, statusCode, details = null) {
    super(message)
    this.code = code
    this.statusCode = statusCode
    this.details = details
  }
}

// Returns seconds until the oldest of N most-recent matching requests falls
// out of the 1-hour rolling window — i.e. when the next slot opens up.
const computeRetryAfterFromRollingWindow = async (whereClauseFragment) => {
  const [row] = await whereClauseFragment
  if (!row) return null
  const oldestTs = new Date(row.created_at).getTime()
  const slotOpensAt = oldestTs + 60 * 60 * 1000
  return Math.max(1, Math.ceil((slotOpensAt - Date.now()) / 1000))
}

const checkRateLimits = async ({ phone, ipAddress, limits }) => {
  // Resend cooldown
  const [lastRow] = await sql`
    SELECT created_at FROM otp_requests
    WHERE phone = ${phone}
    ORDER BY created_at DESC LIMIT 1
  `
  if (lastRow) {
    const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
    if (elapsed < limits.resend_cooldown_seconds) {
      const retryAfter = Math.ceil(limits.resend_cooldown_seconds - elapsed)
      throw new OtpError(
        `Please wait ${retryAfter}s before requesting another OTP`,
        'OTP_COOLDOWN', 429,
        { retry_after_seconds: retryAfter },
      )
    }
  }

  // Per-phone hourly limit
  const [{ phone_count }] = await sql`
    SELECT COUNT(*)::int AS phone_count FROM otp_requests
    WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
  `
  if (phone_count >= limits.max_per_phone_per_hour) {
    const retryAfter = await computeRetryAfterFromRollingWindow(sql`
      SELECT created_at FROM otp_requests
      WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
      ORDER BY created_at ASC LIMIT 1
    `)
    throw new OtpError(
      'Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429,
      retryAfter ? { retry_after_seconds: retryAfter } : null,
    )
  }

  // Per-IP hourly limit (only if ip provided)
  if (ipAddress) {
    const [{ ip_count }] = await sql`
      SELECT COUNT(*)::int AS ip_count FROM otp_requests
      WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
    `
    if (ip_count >= limits.max_per_ip_per_hour) {
      const retryAfter = await computeRetryAfterFromRollingWindow(sql`
        SELECT created_at FROM otp_requests
        WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
        ORDER BY created_at ASC LIMIT 1
      `)
      throw new OtpError(
        'Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429,
        retryAfter ? { retry_after_seconds: retryAfter } : null,
      )
    }
  }
}

/**
 * Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
 * Does NOT return the code to the caller.
 */
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP }) => {
  if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
    throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
  }
  if (userType !== UserType.CUSTOMER && userType !== UserType.MITRA) {
    throw new OtpError('Invalid user type', 'USER_TYPE_INVALID', 400)
  }

  const limits = await getOtpRateLimits()
  await checkRateLimits({ phone, ipAddress, limits })

  const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })

  // Store the reference. In stub mode, we also store the expected code appended
  // after a colon so the verify stub can compare. Real Fazpass flow will NOT store
  // the code; Fazpass itself holds it. This line is the main place to change
  // when switching to real Fazpass.
  const storedReference = code ? `${reference}:${code}` : reference

  const [row] = await sql`
    INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
    VALUES (
      ${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
      NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
    )
    RETURNING id, expires_at
  `

  return {
    otp_request_id: row.id,
    channel_used,
    expires_at: row.expires_at,
  }
}

/**
 * Verify an OTP code. Returns { phone, user_type } on success.
 * Throws OtpError on failure.
 */
export const verifyOtp = async ({ otpRequestId, code }) => {
  if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
    throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
  }

  const limits = await getOtpRateLimits()

  const [row] = await sql`
    SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at
    FROM otp_requests
    WHERE id = ${otpRequestId}
  `
  if (!row) {
    throw new OtpError('OTP request not found', 'OTP_NOT_FOUND', 404)
  }
  if (row.used_at) {
    throw new OtpError('OTP already used', 'OTP_USED', 409)
  }
  if (new Date(row.expires_at) <= new Date()) {
    throw new OtpError('OTP expired', 'OTP_EXPIRED', 410)
  }
  if (row.attempts >= limits.verify_max_attempts) {
    throw new OtpError('Too many verification attempts; request a new OTP', 'OTP_ATTEMPTS_EXCEEDED', 429)
  }

  await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`

  // Stub: fazpass_reference is stored as "<ref>:<code>"
  const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
  const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })

  if (!valid) {
    throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
  }

  await sql`UPDATE otp_requests SET used_at = NOW() WHERE id = ${otpRequestId}`

  return { phone: row.phone, user_type: row.user_type }
}
