import {
  getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
  parsePaymentRequest
} from 'ln-service'
import crypto, { timingSafeEqual } from 'crypto'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import {
  USER_ID, INVOICE_RETENTION_DAYS,
  PAID_ACTION_PAYMENT_METHODS,
  WALLET_CREATE_INVOICE_TIMEOUT_MS,
  WALLET_RETRY_AFTER_MS,
  WALLET_RETRY_BEFORE_MS,
  WALLET_MAX_RETRIES
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive, getWalletByType } from '@/wallets/common'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
import { timeoutSignal, withTimeout } from '@/lib/time'

function injectResolvers (resolvers) {
  console.group('injected GraphQL resolvers:')
  for (const walletDef of walletDefs) {
    const resolverName = generateResolverName(walletDef.walletField)
    console.log(resolverName)
    resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
      console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })

      let existingVaultEntries
      if (typeof vaultEntries === 'undefined' && data.id) {
        // this mutation was sent from an unsynced client
        // to pass validation, we need to add the existing vault entries for validation
        // in case the client is removing the receiving config
        existingVaultEntries = await models.vaultEntry.findMany({
          where: {
            walletId: Number(data.id)
          }
        })
      }

      const validData = await validateWallet(walletDef,
        { ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
        { serverSide: true })
      if (validData) {
        data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
        settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
      }

      // wallet in shape of db row
      const wallet = {
        field: walletDef.walletField,
        type: walletDef.walletType,
        userId: me?.id
      }
      const logger = walletLogger({ wallet, models })

      return await upsertWallet({
        wallet,
        walletDef,
        testCreateInvoice:
          walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
            ? (data) => withTimeout(
                walletDef.testCreateInvoice(data, {
                  logger,
                  signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
                }),
                WALLET_CREATE_INVOICE_TIMEOUT_MS)
            : null
      }, {
        settings,
        data,
        vaultEntries
      }, { logger, me, models })
    }
  }
  console.groupEnd()

  return resolvers
}

export async function getInvoice (parent, { id }, { me, models, lnd }) {
  const inv = await models.invoice.findUnique({
    where: {
      id: Number(id)
    }
  })

  if (!inv) {
    throw new GqlInputError('invoice not found')
  }

  if (inv.userId === USER_ID.anon) {
    return inv
  }
  if (!me) {
    throw new GqlAuthenticationError()
  }
  if (inv.userId !== me.id) {
    throw new GqlInputError('not ur invoice')
  }

  return inv
}

export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
  if (!me) {
    throw new GqlAuthenticationError()
  }

  const wdrwl = await models.withdrawl.findUnique({
    where: {
      id: Number(id)
    }
  })

  if (!wdrwl) {
    throw new GqlInputError('withdrawal not found')
  }

  if (wdrwl.userId !== me.id) {
    throw new GqlInputError('not ur withdrawal')
  }

  return wdrwl
}

export function createHmac (hash) {
  if (!hash) throw new GqlInputError('hash required to create hmac')
  const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
  return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
}

export function verifyHmac (hash, hmac) {
  if (!hash || !hmac) throw new GqlInputError('hash or hmac missing')
  const hmac2 = createHmac(hash)
  if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
    throw new GqlAuthorizationError('bad hmac')
  }
  return true
}

const resolvers = {
  Query: {
    invoice: getInvoice,
    wallet: async (parent, { id }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      return await models.wallet.findUnique({
        where: {
          userId: me.id,
          id: Number(id)
        },
        include: {
          vaultEntries: true
        }
      })
    },
    walletByType: async (parent, { type }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      const wallet = await models.wallet.findFirst({
        where: {
          userId: me.id,
          type
        },
        include: {
          vaultEntries: true
        }
      })
      return wallet
    },
    wallets: async (parent, args, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      return await models.wallet.findMany({
        include: {
          vaultEntries: true
        },
        where: {
          userId: me.id
        },
        orderBy: {
          priority: 'asc'
        }
      })
    },
    withdrawl: getWithdrawl,
    direct: async (parent, { id }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      return await models.directPayment.findUnique({
        where: {
          id: Number(id),
          receiverId: me.id
        }
      })
    },
    numBolt11s: async (parent, args, { me, models, lnd }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      return await models.withdrawl.count({
        where: {
          userId: me.id,
          hash: { not: null }
        }
      })
    },
    connectAddress: async (parent, args, { lnd }) => {
      return process.env.LND_CONNECT_ADDRESS
    },
    walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
      const decodedCursor = decodeCursor(cursor)
      if (!me) {
        throw new GqlAuthenticationError()
      }

      const include = new Set(inc?.split(','))
      const queries = []

      if (include.has('invoice')) {
        queries.push(
          `(SELECT
              id, created_at as "createdAt", COALESCE("msatsReceived", "msatsRequested") as msats,
              'invoice' as type,
              jsonb_build_object(
                'bolt11', bolt11,
                'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
                              WHEN cancelled THEN 'CANCELLED'
                              WHEN "expiresAt" <= $2 AND NOT "isHeld" THEN 'EXPIRED'
                              ELSE 'PENDING' END,
                'description', "desc",
                'invoiceComment', comment,
                'invoicePayerData', "lud18Data") as other
            FROM "Invoice"
            WHERE "userId" = $1
            AND created_at <= $2)`
        )
      }

      if (include.has('withdrawal')) {
        queries.push(
          `(SELECT
              "Withdrawl".id, "Withdrawl".created_at as "createdAt",
              COALESCE("msatsPaid", "msatsPaying") as msats,
              CASE WHEN bool_and("InvoiceForward".id IS NULL) THEN 'withdrawal' ELSE 'p2p' END as type,
              jsonb_build_object(
                'bolt11', "Withdrawl".bolt11,
                'autoWithdraw', "autoWithdraw",
                'status', COALESCE(status::text, 'PENDING'),
                'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
            FROM "Withdrawl"
            LEFT JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
            WHERE "Withdrawl"."userId" = $1
            AND "Withdrawl".created_at <= $2
            GROUP BY "Withdrawl".id)`
        )
        queries.push(
          `(SELECT id, created_at as "createdAt", msats, 'direct' as type,
              jsonb_build_object(
                'bolt11', bolt11,
                'description', "desc",
                'invoiceComment', comment,
                'invoicePayerData', "lud18Data") as other
            FROM "DirectPayment"
            WHERE "DirectPayment"."receiverId" = $1
            AND "DirectPayment".created_at <= $2)`
        )
      }

      if (include.has('stacked')) {
        // query1 - get all sats stacked as OP or as a forward
        queries.push(
          `(SELECT
              "Item".id,
              MAX("ItemAct".created_at) AS "createdAt",
              FLOOR(
                SUM("ItemAct".msats)
                * (CASE WHEN "Item"."userId" = $1 THEN
                    COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1)
                  ELSE
                    (SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
                  END)
              ) AS msats,
              'stacked' AS type, NULL::JSONB AS other
            FROM "ItemAct"
            JOIN "Item" ON "ItemAct"."itemId" = "Item".id
            -- only join to with item forward for items where we aren't the OP
            LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1
            WHERE "ItemAct".act = 'TIP'
            AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
            AND "ItemAct".created_at <= $2
            AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
            GROUP BY "Item".id)`
        )
        queries.push(
          `(SELECT
              min("Earn".id) as id, created_at as "createdAt",
              sum(msats) as msats, 'earn' as type, NULL::JSONB AS other
            FROM "Earn"
            WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
            GROUP BY "userId", created_at)`
        )
        queries.push(
          `(SELECT id, created_at as "createdAt", msats, 'referral' as type, NULL::JSONB AS other
            FROM "ReferralAct"
            WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`
        )
        queries.push(
          `(SELECT id, created_at as "createdAt", msats, 'revenue' as type,
              jsonb_build_object('subName', "SubAct"."subName") as other
            FROM "SubAct"
            WHERE "userId" = $1 AND type = 'REVENUE'
            AND created_at <= $2)`
        )
      }

      if (include.has('spent')) {
        queries.push(
          `(SELECT "Item".id, MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
              'spent' as type, NULL::JSONB AS other
            FROM "ItemAct"
            JOIN "Item" on "ItemAct"."itemId" = "Item".id
            WHERE "ItemAct"."userId" = $1
            AND "ItemAct".created_at <= $2
            AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
            GROUP BY "Item".id)`
        )
        queries.push(
          `(SELECT id, created_at as "createdAt", sats * 1000 as msats,'donation' as type, NULL::JSONB AS other
            FROM "Donation"
            WHERE "userId" = $1
            AND created_at <= $2)`
        )
        queries.push(
            `(SELECT id, created_at as "createdAt", msats, 'billing' as type,
                jsonb_build_object('subName', "SubAct"."subName") as other
              FROM "SubAct"
              WHERE "userId" = $1 AND type = 'BILLING'
              AND created_at <= $2)`
        )
      }

      if (queries.length === 0) {
        return {
          cursor: null,
          facts: []
        }
      }

      let history = await models.$queryRawUnsafe(`
        ${queries.join(' UNION ALL ')}
        ORDER BY "createdAt" DESC
        OFFSET $3
        LIMIT ${LIMIT}`,
      me.id, decodedCursor.time, decodedCursor.offset)

      history = history.map(f => {
        f = { ...f, ...f.other }

        if (f.bolt11) {
          f.description = bolt11Tags(f.bolt11).description
        }

        switch (f.type) {
          case 'withdrawal':
            f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
            break
          case 'p2p':
            f.msats = -1 * Number(f.msats)
            break
          case 'spent':
          case 'donation':
          case 'billing':
            f.msats *= -1
            break
          default:
            break
        }

        return f
      })

      return {
        cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
        facts: history
      }
    },
    walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      // we cursoring with the wallet logs on the client
      // if we have from, don't use cursor
      // regardless, store the state of the cursor for the next call

      const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }

      let logs = []
      let nextCursor
      if (from) {
        logs = await models.walletLog.findMany({
          where: {
            userId: me.id,
            wallet: type ?? undefined,
            createdAt: {
              gt: from ? new Date(Number(from)) : undefined,
              lte: to ? new Date(Number(to)) : undefined
            }
          },
          orderBy: [
            { createdAt: 'desc' },
            { id: 'desc' }
          ]
        })
        nextCursor = nextCursorEncoded(decodedCursor, logs.length)
      } else {
        logs = await models.walletLog.findMany({
          where: {
            userId: me.id,
            wallet: type ?? undefined,
            createdAt: {
              lte: decodedCursor.time
            }
          },
          orderBy: [
            { createdAt: 'desc' },
            { id: 'desc' }
          ],
          take: LIMIT,
          skip: decodedCursor.offset
        })
        nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
      }

      return {
        cursor: nextCursor,
        entries: logs
      }
    },
    failedInvoices: async (parent, args, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }
      return await models.$queryRaw`
        SELECT * FROM "Invoice"
        WHERE "userId" = ${me.id}
        AND "actionState" = 'FAILED'
        -- never retry if user has cancelled the invoice manually
        AND "userCancel" = false
        AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
        AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
        AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
        ORDER BY id DESC`
    }
  },
  Wallet: {
    wallet: async (wallet) => {
      return {
        ...wallet.wallet,
        __resolveType: generateTypeDefName(wallet.type)
      }
    }
  },
  WalletDetails: {
    __resolveType: wallet => wallet.__resolveType
  },
  InvoiceOrDirect: {
    __resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
  },
  Mutation: {
    createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
      await validateSchema(amountSchema, { amount })
      await assertGofacYourself({ models, headers })

      const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
        msats: satsToMsats(amount)
      }, { models, lnd, me })

      return {
        ...invoice,
        __resolveType:
          paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
      }
    },
    createWithdrawl: createWithdrawal,
    sendToLnAddr,
    cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
      // stackers can cancel their own invoices without hmac
      if (me && !hmac) {
        const inv = await models.invoice.findUnique({ where: { hash } })
        if (!inv) throw new GqlInputError('invoice not found')
        if (inv.userId !== me.id) throw new GqlInputError('not ur invoice')
      } else {
        verifyHmac(hash, hmac)
      }
      await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
      return await models.invoice.update({ where: { hash }, data: { userCancel: !!userCancel } })
    },
    dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      const retention = `${INVOICE_RETENTION_DAYS} days`

      const [invoice] = await models.$queryRaw`
        WITH to_be_updated AS (
          SELECT id, hash, bolt11
          FROM "Withdrawl"
          WHERE "userId" = ${me.id}
          AND hash = ${hash}
          AND now() > created_at + ${retention}::INTERVAL
          AND hash IS NOT NULL
          AND status IS NOT NULL
        ), updated_rows AS (
          UPDATE "Withdrawl"
          SET hash = NULL, bolt11 = NULL, preimage = NULL
          FROM to_be_updated
          WHERE "Withdrawl".id = to_be_updated.id)
        SELECT * FROM to_be_updated;`

      if (invoice) {
        try {
          await deletePayment({ id: invoice.hash, lnd })
        } catch (error) {
          console.error(error)
          await models.withdrawl.update({
            where: { id: invoice.id },
            data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage }
          })
          throw new GqlInputError('failed to drop bolt11 from lnd')
        }
      }

      await models.$queryRaw`
        UPDATE "DirectPayment"
        SET hash = NULL, bolt11 = NULL, preimage = NULL
        WHERE "receiverId" = ${me.id}
        AND hash = ${hash}
        AND now() > created_at + ${retention}::INTERVAL
        AND hash IS NOT NULL`

      return true
    },
    setWalletPriority: async (parent, { id, priority }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })

      return true
    },
    removeWallet: async (parent, { id }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
      if (!wallet) {
        throw new GqlInputError('wallet not found')
      }

      const logger = walletLogger({ wallet, models })
      await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })

      if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) {
        logger.info('details for receiving deleted')
      }

      return true
    },
    deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
      if (!me) {
        throw new GqlAuthenticationError()
      }

      await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })

      return true
    },
    buyCredits: async (parent, { credits }, { me, models, lnd }) => {
      return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
    }
  },

  Withdrawl: {
    satsPaying: w => msatsToSats(w.msatsPaying),
    satsPaid: w => msatsToSats(w.msatsPaid),
    satsFeePaying: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaying),
    satsFeePaid: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaid),
    // we never want to fetch the sensitive data full monty in nested resolvers
    forwardedActionType: async (withdrawl, args, { models }) => {
      return (await models.invoiceForward.findUnique({
        where: { withdrawlId: Number(withdrawl.id) },
        include: {
          invoice: true
        }
      }))?.invoice?.actionType
    },
    preimage: async (withdrawl, args, { lnd }) => {
      try {
        if (withdrawl.status === 'CONFIRMED' && withdrawl.hash) {
          return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret
        }
      } catch (err) {
        console.error('error fetching payment from LND', err)
      }
    }
  },
  Direct: {
    nostr: async (direct, args, { models }) => {
      try {
        return JSON.parse(direct.desc)
      } catch (err) {
      }

      return null
    },
    sats: direct => msatsToSats(direct.msats)
  },

  Invoice: {
    satsReceived: i => msatsToSats(i.msatsReceived),
    satsRequested: i => msatsToSats(i.msatsRequested),
    // we never want to fetch the sensitive data full monty in nested resolvers
    forwardStatus: async (invoice, args, { models }) => {
      const forward = await models.invoiceForward.findUnique({
        where: { invoiceId: Number(invoice.id) },
        include: {
          withdrawl: true
        }
      })
      return forward?.withdrawl?.status
    },
    forwardedSats: async (invoice, args, { models }) => {
      const msats = (await models.invoiceForward.findUnique({
        where: { invoiceId: Number(invoice.id) },
        include: {
          withdrawl: true
        }
      }))?.withdrawl?.msatsPaid
      return msats ? msatsToSats(msats) : null
    },
    invoiceForward: async (invoice, args, { models }) => {
      return !!invoice.invoiceForward || !!(await models.invoiceForward.findUnique({ where: { invoiceId: Number(invoice.id) } }))
    },
    nostr: async (invoice, args, { models }) => {
      try {
        return JSON.parse(invoice.desc)
      } catch (err) {
      }

      return null
    },
    confirmedPreimage: async (invoice, args, { lnd }) => {
      try {
        if (invoice.confirmedAt) {
          return invoice.preimage ?? (await getInvoiceFromLnd({ id: invoice.hash, lnd })).secret
        }
      } catch (err) {
        console.error('error fetching invoice from LND', err)
      }

      return null
    },
    item: async (invoice, args, { models, me }) => {
      if (!invoice.actionId) return null
      switch (invoice.actionType) {
        case 'ITEM_CREATE':
        case 'ZAP':
        case 'DOWN_ZAP':
        case 'POLL_VOTE':
        case 'BOOST':
          return (await itemQueryWithMeta({
            me,
            models,
            query: `
              ${SELECT}
              FROM "Item"
              WHERE id = $1`
          }, Number(invoice.actionId)))?.[0]
        default:
          return null
      }
    },
    itemAct: async (invoice, args, { models, me }) => {
      const action2act = {
        ZAP: 'TIP',
        DOWN_ZAP: 'DONT_LIKE_THIS',
        POLL_VOTE: 'POLL',
        BOOST: 'BOOST'
      }
      switch (invoice.actionType) {
        case 'ZAP':
        case 'DOWN_ZAP':
        case 'POLL_VOTE':
        case 'BOOST':
          return (await models.$queryRaw`
              SELECT id, act, "invoiceId", "invoiceActionState", msats
              FROM "ItemAct"
              WHERE "ItemAct"."invoiceId" = ${Number(invoice.id)}::INTEGER
              AND "ItemAct"."userId" = ${me?.id}::INTEGER
              AND act = ${action2act[invoice.actionType]}::"ItemActType"`
          )?.[0]
        default:
          return null
      }
    }
  },

  Fact: {
    item: async (fact, args, { models }) => {
      if (fact.type !== 'spent' && fact.type !== 'stacked') {
        return null
      }
      const [item] = await models.$queryRawUnsafe(`
        ${SELECT}
        FROM "Item"
        WHERE id = $1`, Number(fact.id))

      return item
    },
    sats: fact => msatsToSatsDecimal(fact.msats)
  }
}

export default injectResolvers(resolvers)

export const walletLogger = ({ wallet, models }) => {
  // no-op logger if wallet is not provided
  if (!wallet) {
    return {
      ok: () => {},
      info: () => {},
      error: () => {},
      warn: () => {}
    }
  }

  // server implementation of wallet logger interface on client
  const log = (level) => async (message, context = {}) => {
    try {
      if (context?.bolt11) {
        // automatically populate context from bolt11 to avoid duplicating this code
        const decoded = await parsePaymentRequest({ request: context.bolt11 })
        context = {
          ...context,
          amount: formatMsats(decoded.mtokens),
          payment_hash: decoded.id,
          created_at: decoded.created_at,
          expires_at: decoded.expires_at,
          description: decoded.description,
          // payments should affect wallet status
          status: true
        }
      }
      context.recv = true

      await models.walletLog.create({
        data: {
          userId: wallet.userId,
          wallet: wallet.type,
          level,
          message,
          context
        }
      })
    } catch (err) {
      console.error('error creating wallet log:', err)
    }
  }

  return {
    ok: (message, context) => log('SUCCESS')(message, context),
    info: (message, context) => log('INFO')(message, context),
    error: (message, context) => log('ERROR')(message, context),
    warn: (message, context) => log('WARN')(message, context)
  }
}

async function upsertWallet (
  { wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
  if (!me) {
    throw new GqlAuthenticationError()
  }
  assertApiKeyNotPermitted({ me })

  if (testCreateInvoice) {
    try {
      await testCreateInvoice(data)
    } catch (err) {
      const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
      logger.error(message)
      throw new GqlInputError(message)
    }
  }

  const { id, enabled, priority, ...recvConfig } = data

  const txs = []

  if (id) {
    const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })

    // createMany is the set difference of the new - old
    // deleteMany is the set difference of the old - new
    // updateMany is the intersection of the old and new
    const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
    const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
      .map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))

    txs.push(
      models.wallet.update({
        where: { id: Number(id), userId: me.id },
        data: {
          enabled,
          priority,
          // client only wallets have no receive config and thus don't have their own table
          ...(Object.keys(recvConfig).length > 0
            ? {
                [wallet.field]: {
                  upsert: {
                    create: recvConfig,
                    update: recvConfig
                  }
                }
              }
            : {}),
          ...(vaultEntries
            ? {
                vaultEntries: {
                  deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
                    userId: me.id, key
                  })),
                  create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
                    key, iv, value, userId: me.id
                  })),
                  update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
                    where: { userId_key: { userId: me.id, key } },
                    data: { value, iv }
                  }))
                }
              }
            : {})

        },
        include: {
          vaultEntries: true
        }
      })
    )
  } else {
    txs.push(
      models.wallet.create({
        include: {
          vaultEntries: true
        },
        data: {
          enabled,
          priority,
          userId: me.id,
          type: wallet.type,
          // client only wallets have no receive config and thus don't have their own table
          ...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}),
          ...(vaultEntries
            ? {
                vaultEntries: {
                  createMany: {
                    data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
                  }
                }
              }
            : {})
        }
      })
    )
  }

  if (settings) {
    txs.push(
      models.user.update({
        where: { id: me.id },
        data: settings
      })
    )
  }

  if (canReceive({ def: walletDef, config: recvConfig })) {
    txs.push(
      models.walletLog.createMany({
        data: {
          userId: me.id,
          wallet: wallet.type,
          level: 'SUCCESS',
          message: id ? 'details for receiving updated' : 'details for receiving saved'
        }
      }),
      models.walletLog.create({
        data: {
          userId: me.id,
          wallet: wallet.type,
          level: enabled ? 'SUCCESS' : 'INFO',
          message: enabled ? 'receiving enabled' : 'receiving disabled'
        }
      })
    )
  }

  const [upsertedWallet] = await models.$transaction(txs)
  return upsertedWallet
}

export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
  assertApiKeyNotPermitted({ me })
  await validateSchema(withdrawlSchema, { invoice, maxFee })
  await assertGofacYourself({ models, headers })

  // remove 'lightning:' prefix if present
  invoice = invoice.replace(/^lightning:/, '')

  // decode invoice to get amount
  let decoded, sockets
  try {
    decoded = await parsePaymentRequest({ request: invoice })
  } catch (error) {
    console.log(error)
    throw new GqlInputError('could not decode invoice')
  }

  try {
    sockets = await getNodeSockets({ lnd, public_key: decoded.destination })
  } catch (error) {
    // likely not found if it's an unannounced channel, e.g. phoenix
    console.log(error)
  }

  if (sockets) {
    for (const { socket } of sockets) {
      const ip = socket.split(':')[0]
      await assertGofacYourself({ models, headers, ip })
    }
  }

  if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
    throw new GqlInputError('invoice must specify an amount')
  }

  if (decoded.mtokens > Number.MAX_SAFE_INTEGER) {
    throw new GqlInputError('invoice amount is too large')
  }

  // check if there's an invoice with same hash that has an invoiceForward
  // we can't allow this because it creates two outgoing payments from our node
  // with the same hash
  const selfPayment = await models.invoice.findUnique({
    where: { hash: decoded.id },
    include: { invoiceForward: true }
  })
  if (selfPayment?.invoiceForward) {
    throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
  }

  return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
}

export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
  { me, models, lnd, headers }) {
  if (!me) {
    throw new GqlAuthenticationError()
  }
  assertApiKeyNotPermitted({ me })

  const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer },
    {
      me,
      models,
      lnd
    })

  // take pr and createWithdrawl
  return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
}

export async function fetchLnAddrInvoice (
  { addr, amount, maxFee, comment, ...payer },
  {
    me, models, lnd, autoWithdraw = false
  }) {
  const options = await lnAddrOptions(addr)
  await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)

  if (payer) {
    payer = {
      ...payer,
      identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
    }
    payer = Object.fromEntries(
      Object.entries(payer).filter(([, value]) => !!value)
    )
  }

  const milliamount = 1000 * amount
  const callback = new URL(options.callback)
  callback.searchParams.append('amount', milliamount)

  if (comment?.length) {
    callback.searchParams.append('comment', comment)
  }

  let stringifiedPayerData = ''
  if (payer && Object.entries(payer).length) {
    stringifiedPayerData = JSON.stringify(payer)
    callback.searchParams.append('payerdata', stringifiedPayerData)
  }

  // call callback with amount and conditionally comment
  const res = await (await fetch(callback.toString())).json()
  if (res.status === 'ERROR') {
    throw new Error(res.reason)
  }

  // decode invoice
  try {
    const decoded = await parsePaymentRequest({ request: res.pr })
    const ourPubkey = await getOurPubkey({ lnd })
    if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
      // unset lnaddr so we don't trigger another withdrawal with same destination
      await models.wallet.deleteMany({
        where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
      })
      throw new Error('automated withdrawals to other stackers are not allowed')
    }
    if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
      throw new Error('invoice has incorrect amount')
    }
  } catch (e) {
    console.log(e)
    throw e
  }

  return res
}