import { createHodlInvoice, createInvoice } from 'ln-service'
import { datePivot } from '@/lib/time'
import { USER_ID } from '@/lib/constants'
import { createHmac } from '../resolvers/wallet'
import { Prisma } from '@prisma/client'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap'
import * as DOWN_ZAP from './downZap'
import * as POLL_VOTE from './pollVote'
import * as TERRITORY_CREATE from './territoryCreate'
import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'

export const paidActions = {
  ITEM_CREATE,
  ITEM_UPDATE,
  ZAP,
  DOWN_ZAP,
  POLL_VOTE,
  TERRITORY_CREATE,
  TERRITORY_UPDATE,
  TERRITORY_BILLING,
  TERRITORY_UNARCHIVE,
  DONATE
}

export default async function performPaidAction (actionType, args, context) {
  try {
    const { me, models, forceFeeCredits } = context
    const paidAction = paidActions[actionType]

    console.group('performPaidAction', actionType, args)

    if (!paidAction) {
      throw new Error(`Invalid action type ${actionType}`)
    }

    context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
    context.cost = await paidAction.getCost(args, context)

    if (!me) {
      if (!paidAction.anonable) {
        throw new Error('You must be logged in to perform this action')
      }

      console.log('we are anon so can only perform pessimistic action')
      return await performPessimisticAction(actionType, args, context)
    }

    const isRich = context.cost <= context.me.msats
    if (isRich) {
      try {
        console.log('enough fee credits available, performing fee credit action')
        return await performFeeCreditAction(actionType, args, context)
      } catch (e) {
        console.error('fee credit action failed', e)

        // if we fail with fee credits, but not because of insufficient funds, bail
        if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
          throw e
        }
      }
    }

    // this is set if the worker executes a paid action in behalf of a user.
    // in that case, only payment via fee credits is possible
    // since there is no client to which we could send an invoice.
    // example: automated territory billing
    if (forceFeeCredits) {
      throw new Error('forceFeeCredits is set, but user does not have enough fee credits')
    }

    // if we fail to do the action with fee credits, we should fall back to optimistic
    if (paidAction.supportsOptimism) {
      console.log('performing optimistic action')
      return await performOptimisticAction(actionType, args, context)
    }

    console.error('action does not support optimism and fee credits failed, performing pessimistic action')
    return await performPessimisticAction(actionType, args, context)
  } catch (e) {
    console.error('performPaidAction failed', e)
    throw e
  } finally {
    console.groupEnd()
  }
}

async function performFeeCreditAction (actionType, args, context) {
  const { me, models, cost } = context
  const action = paidActions[actionType]

  return await models.$transaction(async tx => {
    context.tx = tx

    await tx.user.update({
      where: {
        id: me.id
      },
      data: {
        msats: {
          decrement: cost
        }
      }
    })

    const result = await action.perform(args, context)
    await action.onPaid?.(result, context)

    return {
      result,
      paymentMethod: 'FEE_CREDIT'
    }
  }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}

async function performOptimisticAction (actionType, args, context) {
  const { models } = context
  const action = paidActions[actionType]

  context.optimistic = true
  context.lndInvoice = await createLndInvoice(actionType, args, context)

  return await models.$transaction(async tx => {
    context.tx = tx

    const invoice = await createDbInvoice(actionType, args, context)

    return {
      invoice,
      result: await action.perform?.({ invoiceId: invoice.id, ...args }, context),
      paymentMethod: 'OPTIMISTIC'
    }
  }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}

async function performPessimisticAction (actionType, args, context) {
  const action = paidActions[actionType]

  if (!action.supportsPessimism) {
    throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
  }

  // just create the invoice and complete action when it's paid
  context.lndInvoice = await createLndInvoice(actionType, args, context)
  return {
    invoice: await createDbInvoice(actionType, args, context),
    paymentMethod: 'PESSIMISTIC'
  }
}

export async function retryPaidAction (actionType, args, context) {
  const { models, me } = context
  const { invoiceId } = args

  const action = paidActions[actionType]
  if (!action) {
    throw new Error(`retryPaidAction - invalid action type ${actionType}`)
  }

  if (!me) {
    throw new Error(`retryPaidAction - must be logged in ${actionType}`)
  }

  if (!action.supportsOptimism) {
    throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
  }

  if (!action.retry) {
    throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
  }

  if (!invoiceId) {
    throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
  }

  context.optimistic = true
  context.me = await models.user.findUnique({ where: { id: me.id } })

  const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
  context.cost = BigInt(msatsRequested)
  context.lndInvoice = await createLndInvoice(actionType, args, context)

  return await models.$transaction(async tx => {
    context.tx = tx

    // update the old invoice to RETRYING, so that it's not confused with FAILED
    const { actionId } = await tx.invoice.update({
      where: {
        id: invoiceId,
        actionState: 'FAILED'
      },
      data: {
        actionState: 'RETRYING'
      }
    })

    context.actionId = actionId

    // create a new invoice
    const invoice = await createDbInvoice(actionType, args, context)

    return {
      result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
      invoice,
      paymentMethod: 'OPTIMISTIC'
    }
  }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}

const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }

// we seperate the invoice creation into two functions because
// because if lnd is slow, it'll timeout the interactive tx
async function createLndInvoice (actionType, args, context) {
  const { me, lnd, cost, optimistic } = context
  const action = paidActions[actionType]
  const [createLNDInvoice, expirePivot] = optimistic
    ? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
    : [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE]

  if (cost < 1000n) {
    // sanity check
    throw new Error('The cost of the action must be at least 1 sat')
  }

  const expiresAt = datePivot(new Date(), expirePivot)
  return await createLNDInvoice({
    description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
    lnd,
    mtokens: String(cost),
    expires_at: expiresAt
  })
}

async function createDbInvoice (actionType, args, context) {
  const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
  const db = tx ?? models
  const [expirePivot, actionState] = optimistic
    ? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
    : [PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']

  if (cost < 1000n) {
    // sanity check
    throw new Error('The cost of the action must be at least 1 sat')
  }

  const expiresAt = datePivot(new Date(), expirePivot)
  const invoice = await db.invoice.create({
    data: {
      hash: lndInvoice.id,
      msatsRequested: cost,
      preimage: optimistic ? undefined : lndInvoice.secret,
      bolt11: lndInvoice.request,
      userId: me?.id ?? USER_ID.anon,
      actionType,
      actionState,
      actionArgs: args,
      expiresAt,
      actionId
    }
  })

  // insert a job to check the invoice after it's set to expire
  await db.$executeRaw`
      INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
      VALUES ('checkInvoice',
        jsonb_build_object('hash', ${lndInvoice.id}::TEXT), 21, true,
          ${expiresAt}::TIMESTAMP WITH TIME ZONE,
          ${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`

  // the HMAC is only returned during invoice creation
  // this makes sure that only the person who created this invoice
  // has access to the HMAC
  invoice.hmac = createHmac(invoice.hash)

  return invoice
}