import { createHodlInvoice, createInvoice, parsePaymentRequest } 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'
import wrapInvoice from 'wallets/wrap'
import { createInvoice as createUserInvoice } from 'wallets/server'

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
  const invoiceArgs = await createLightningInvoice(actionType, args, context)

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

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

    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
  const invoiceArgs = await createLightningInvoice(actionType, args, context)
  return {
    invoice: await createDbInvoice(actionType, args, context, invoiceArgs),
    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, actionId } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
  context.cost = BigInt(msatsRequested)
  context.actionId = actionId
  const invoiceArgs = await createSNInvoice(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
    await tx.invoice.update({
      where: {
        id: invoiceId,
        actionState: 'FAILED'
      },
      data: {
        actionState: 'RETRYING'
      }
    })

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

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

const INVOICE_EXPIRE_SECS = 600

export async function createLightningInvoice (actionType, args, context) {
  // if the action has an invoiceable peer, we'll create a peer invoice
  // wrap it, and return the wrapped invoice
  const { cost, models, lnd } = context
  const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)

  if (userId) {
    try {
      const description = await paidActions[actionType].describe(args, context)
      const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
        // this is the amount the stacker will receive, the other 1/10th is the fee
        msats: cost * BigInt(9) / BigInt(10),
        description,
        expiry: INVOICE_EXPIRE_SECS
      }, { models })

      const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
        bolt11, { msats: cost, description }, { lnd })

      return {
        bolt11,
        wrappedBolt11: wrappedInvoice.request,
        wallet,
        maxFee
      }
    } catch (e) {
      console.error('failed to create stacker invoice, falling back to SN invoice', e)
    }
  }

  return await createSNInvoice(actionType, args, context)
}

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

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

  const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS })
  const invoice = await createLNDInvoice({
    description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
    lnd,
    mtokens: String(cost),
    expires_at: expiresAt
  })
  return { bolt11: invoice.request, preimage: invoice.secret }
}

async function createDbInvoice (actionType, args, context,
  { bolt11, wrappedBolt11, preimage, wallet, maxFee }) {
  const { me, models, tx, cost, optimistic, actionId } = context
  const db = tx ?? models

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

  const servedBolt11 = wrappedBolt11 ?? bolt11
  const servedInvoice = parsePaymentRequest({ request: servedBolt11 })
  const expiresAt = new Date(servedInvoice.expires_at)

  const invoiceData = {
    hash: servedInvoice.id,
    msatsRequested: BigInt(servedInvoice.mtokens),
    preimage: optimistic ? undefined : preimage,
    bolt11: servedBolt11,
    userId: me?.id ?? USER_ID.anon,
    actionType,
    actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD',
    actionOptimistic: optimistic,
    actionArgs: args,
    expiresAt,
    actionId
  }

  let invoice
  if (wrappedBolt11) {
    invoice = (await db.invoiceForward.create({
      include: { invoice: true },
      data: {
        bolt11,
        maxFeeMsats: maxFee,
        invoice: {
          create: invoiceData
        },
        wallet: {
          connect: {
            id: wallet.id
          }
        }
      }
    })).invoice
  } else {
    invoice = await db.invoice.create({ data: invoiceData })
  }

  // 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', ${invoice.hash}::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
}