# fluffy
# Copyright (c) 2022-2024 Status Research & Development GmbH
# Licensed and distributed under either of
#   * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
#   * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.

{.push raises: [].}

import
  chronicles,
  metrics,
  eth/db/kvstore,
  eth/db/kvstore_sqlite3,
  stint,
  results,
  ssz_serialization,
  beacon_chain/db_limits,
  beacon_chain/spec/forks,
  beacon_chain/spec/forks_light_client,
  ./beacon_content,
  ./beacon_chain_historical_summaries,
  ./beacon_init_loader,
  ../wire/[portal_protocol, portal_protocol_config]

from beacon_chain/spec/helpers import is_better_update, toMeta

export kvstore_sqlite3

type
  BestLightClientUpdateStore = ref object
    getStmt: SqliteStmt[int64, seq[byte]]
    getBulkStmt: SqliteStmt[(int64, int64), seq[byte]]
    putStmt: SqliteStmt[(int64, seq[byte]), void]
    delStmt: SqliteStmt[int64, void]
    keepFromStmt: SqliteStmt[int64, void]

  BootstrapStore = ref object
    getStmt: SqliteStmt[array[32, byte], seq[byte]]
    getLatestStmt: SqliteStmt[NoParams, seq[byte]]
    putStmt: SqliteStmt[(array[32, byte], seq[byte], int64), void]
    keepFromStmt: SqliteStmt[int64, void]

  BeaconDb* = ref object
    backend: SqStoreRef
    kv: KvStoreRef # TODO: kv only used for summaries at this point
    dataRadius*: UInt256
    bootstraps: BootstrapStore
    bestUpdates: BestLightClientUpdateStore
    forkDigests: ForkDigests
    cfg*: RuntimeConfig
    finalityUpdateCache: Opt[LightClientFinalityUpdateCache]
    optimisticUpdateCache: Opt[LightClientOptimisticUpdateCache]

  # Storing the content encoded here. Could also store decoded and access the
  # slot directly. However, that would require is to have access to the
  # fork digests here to be able the re-encode the data.
  LightClientFinalityUpdateCache = object
    lastFinalityUpdate: seq[byte]
    lastFinalityUpdateSlot: uint64

  LightClientOptimisticUpdateCache = object
    lastOptimisticUpdate: seq[byte]
    lastOptimisticUpdateSlot: uint64

template expectDb(x: auto): untyped =
  # There's no meaningful error handling implemented for a corrupt database or
  # full disk - this requires manual intervention, so we'll panic for now
  x.expect("working database (disk broken/full?)")

template disposeSafe(s: untyped): untyped =
  if distinctBase(s) != nil:
    s.dispose()
    s = typeof(s)(nil)

proc initBootstrapStore(backend: SqStoreRef, name: string): KvResult[BootstrapStore] =
  ?backend.exec(
    """
    CREATE TABLE IF NOT EXISTS `""" & name &
      """` (
      `contentId` BLOB PRIMARY KEY, -- `ContentId`
      `bootstrap` BLOB,             -- `LightClientBootstrap` (SSZ)
      `slot` INTEGER UNIQUE         -- `Slot`
    );
  """
  )

  let
    getStmt = backend
      .prepareStmt(
        """
        SELECT `bootstrap`
        FROM `""" & name &
          """`
        WHERE `contentId` = ?;
      """,
        array[32, byte],
        seq[byte],
        managed = false,
      )
      .expect("SQL query OK")
    getLatestStmt = backend
      .prepareStmt(
        """
        SELECT `bootstrap`
        FROM `""" & name &
          """`
        WHERE `slot` = (SELECT MAX(slot) FROM `""" & name &
          """`);
      """,
        NoParams,
        seq[byte],
        managed = false,
      )
      .expect("SQL query OK")
    putStmt = backend
      .prepareStmt(
        """
        REPLACE INTO `""" & name &
          """` (
        `contentId`, `bootstrap`, `slot`
        ) VALUES (?, ?, ?);
      """,
        (array[32, byte], seq[byte], int64),
        void,
        managed = false,
      )
      .expect("SQL query OK")
    keepFromStmt = backend
      .prepareStmt(
        """
        DELETE FROM `""" & name &
          """`
        WHERE `slot` < ?;
      """,
        int64,
        void,
        managed = false,
      )
      .expect("SQL query OK")

  ok BootstrapStore(
    getStmt: getStmt,
    getLatestStmt: getLatestStmt,
    putStmt: putStmt,
    keepFromStmt: keepFromStmt,
  )

proc initBestUpdateStore(
    backend: SqStoreRef, name: string
): KvResult[BestLightClientUpdateStore] =
  ?backend.exec(
    """
    CREATE TABLE IF NOT EXISTS `""" & name &
      """` (
      `period` INTEGER PRIMARY KEY,  -- `SyncCommitteePeriod`
      `update` BLOB                  -- `LightClientUpdate` (SSZ)
    );
  """
  )

  let
    getStmt = backend
      .prepareStmt(
        """
        SELECT `update`
        FROM `""" & name &
          """`
        WHERE `period` = ?;
      """,
        int64,
        seq[byte],
        managed = false,
      )
      .expect("SQL query OK")
    getBulkStmt = backend
      .prepareStmt(
        """
        SELECT `update`
        FROM `""" & name &
          """`
        WHERE `period` >= ? AND `period` < ?;
      """,
        (int64, int64),
        seq[byte],
        managed = false,
      )
      .expect("SQL query OK")
    putStmt = backend
      .prepareStmt(
        """
        REPLACE INTO `""" & name &
          """` (
          `period`, `update`
        ) VALUES (?, ?);
      """,
        (int64, seq[byte]),
        void,
        managed = false,
      )
      .expect("SQL query OK")
    delStmt = backend
      .prepareStmt(
        """
        DELETE FROM `""" & name &
          """`
        WHERE `period` = ?;
      """,
        int64,
        void,
        managed = false,
      )
      .expect("SQL query OK")
    keepFromStmt = backend
      .prepareStmt(
        """
        DELETE FROM `""" & name &
          """`
        WHERE `period` < ?;
      """,
        int64,
        void,
        managed = false,
      )
      .expect("SQL query OK")

  ok BestLightClientUpdateStore(
    getStmt: getStmt,
    getBulkStmt: getBulkStmt,
    putStmt: putStmt,
    delStmt: delStmt,
    keepFromStmt: keepFromStmt,
  )

func close(store: var BestLightClientUpdateStore) =
  store.getStmt.disposeSafe()
  store.getBulkStmt.disposeSafe()
  store.putStmt.disposeSafe()
  store.delStmt.disposeSafe()
  store.keepFromStmt.disposeSafe()

func close(store: var BootstrapStore) =
  store.getStmt.disposeSafe()
  store.getLatestStmt.disposeSafe()
  store.putStmt.disposeSafe()
  store.keepFromStmt.disposeSafe()

proc new*(
    T: type BeaconDb, networkData: NetworkInitData, path: string, inMemory = false
): BeaconDb =
  let
    db =
      if inMemory:
        SqStoreRef.init("", "lc-test", inMemory = true).expect(
          "working database (out of memory?)"
        )
      else:
        SqStoreRef.init(path, "lc").expectDb()

    kvStore = kvStore db.openKvStore().expectDb()
    bootstraps = initBootstrapStore(db, "lc_bootstraps").expectDb()
    bestUpdates = initBestUpdateStore(db, "lc_best_updates").expectDb()

  BeaconDb(
    backend: db,
    kv: kvStore,
    dataRadius: UInt256.high(), # Radius to max to accept all data
    bootstraps: bootstraps,
    bestUpdates: bestUpdates,
    cfg: networkData.metadata.cfg,
    forkDigests: (newClone networkData.forks)[],
  )

proc close*(db: BeaconDb) =
  db.bootstraps.close()
  db.bestUpdates.close()
  discard db.kv.close()

## Private KvStoreRef Calls
proc get(kv: KvStoreRef, key: openArray[byte]): results.Opt[seq[byte]] =
  var res: results.Opt[seq[byte]] = Opt.none(seq[byte])
  proc onData(data: openArray[byte]) =
    res = ok(@data)

  discard kv.get(key, onData).expectDb()

  return res

## Private BeaconDb calls
proc get(db: BeaconDb, key: openArray[byte]): results.Opt[seq[byte]] =
  db.kv.get(key)

proc put(db: BeaconDb, key, value: openArray[byte]) =
  db.kv.put(key, value).expectDb()

## Public ContentId based ContentDB calls
proc get*(db: BeaconDb, key: ContentId): results.Opt[seq[byte]] =
  # TODO: Here it is unfortunate that ContentId is a uint256 instead of Digest256.
  db.get(key.toBytesBE())

proc put*(db: BeaconDb, key: ContentId, value: openArray[byte]) =
  db.put(key.toBytesBE(), value)

# TODO Add checks that uint64 can be safely casted to int64
proc getLightClientUpdates(
    db: BeaconDb, start: uint64, to: uint64
): ForkedLightClientUpdateBytesList =
  ## Get multiple consecutive LightClientUpdates for given periods
  var updates: ForkedLightClientUpdateBytesList
  var update: seq[byte]
  for res in db.bestUpdates.getBulkStmt.exec((start.int64, to.int64), update):
    res.expect("SQL query OK")
    let byteList = List[byte, MAX_LIGHT_CLIENT_UPDATE_SIZE].init(update)
    discard updates.add(byteList)
  return updates

proc getBestUpdate*(
    db: BeaconDb, period: SyncCommitteePeriod
): Result[ForkedLightClientUpdate, string] =
  ## Get the best ForkedLightClientUpdate for given period
  ## Note: Only the best one for a given period is being stored.
  doAssert period.isSupportedBySQLite
  doAssert distinctBase(db.bestUpdates.getStmt) != nil

  var update: seq[byte]
  for res in db.bestUpdates.getStmt.exec(period.int64, update):
    res.expect("SQL query OK")
    return decodeLightClientUpdateForked(db.forkDigests, update)

proc getBootstrap*(db: BeaconDb, contentId: ContentId): Opt[seq[byte]] =
  doAssert distinctBase(db.bootstraps.getStmt) != nil

  var bootstrap: seq[byte]
  for res in db.bootstraps.getStmt.exec(contentId.toBytesBE(), bootstrap):
    res.expect("SQL query OK")
    return ok(bootstrap)

proc getLatestBootstrap*(db: BeaconDb): Opt[ForkedLightClientBootstrap] =
  doAssert distinctBase(db.bootstraps.getLatestStmt) != nil

  var bootstrap: seq[byte]
  for res in db.bootstraps.getLatestStmt.exec(bootstrap):
    res.expect("SQL query OK")
    let forkedBootstrap = decodeLightClientBootstrapForked(db.forkDigests, bootstrap).valueOr:
      raiseAssert "Stored bootstrap must be valid"
    return ok(forkedBootstrap)

proc getLatestBlockRoot*(db: BeaconDb): Opt[Digest] =
  let bootstrap = db.getLatestBootstrap()
  if bootstrap.isSome():
    withForkyBootstrap(bootstrap.value()):
      when lcDataFork > LightClientDataFork.None:
        Opt.some(hash_tree_root(forkyBootstrap.header.beacon))
      else:
        raiseAssert "Stored bootstrap must >= Altair"
  else:
    Opt.none(Digest)

proc putBootstrap*(
    db: BeaconDb, contentId: ContentId, bootstrap: seq[byte], slot: Slot
) =
  db.bootstraps.putStmt.exec((contentId.toBytesBE(), bootstrap, slot.int64)).expect(
    "SQL query OK"
  )

proc putBootstrap*(
    db: BeaconDb, blockRoot: Digest, bootstrap: ForkedLightClientBootstrap
) =
  # Put a ForkedLightClientBootstrap in the db.
  withForkyBootstrap(bootstrap):
    when lcDataFork > LightClientDataFork.None:
      let
        contentKey = bootstrapContentKey(blockRoot)
        contentId = toContentId(contentKey)
        forkDigest = forkDigestAtEpoch(
          db.forkDigests, epoch(forkyBootstrap.header.beacon.slot), db.cfg
        )
        encodedBootstrap = encodeBootstrapForked(forkDigest, bootstrap)

      db.putBootstrap(contentId, encodedBootstrap, forkyBootstrap.header.beacon.slot)

func putLightClientUpdate*(db: BeaconDb, period: uint64, update: seq[byte]) =
  # Put an encoded ForkedLightClientUpdate in the db.
  let res = db.bestUpdates.putStmt.exec((period.int64, update))
  res.expect("SQL query OK")

func putBestUpdate*(
    db: BeaconDb, period: SyncCommitteePeriod, update: ForkedLightClientUpdate
) =
  # Put a ForkedLightClientUpdate in the db.
  doAssert not db.backend.readOnly # All `stmt` are non-nil
  doAssert period.isSupportedBySQLite
  withForkyUpdate(update):
    when lcDataFork > LightClientDataFork.None:
      let numParticipants = forkyUpdate.sync_aggregate.num_active_participants
      if numParticipants < MIN_SYNC_COMMITTEE_PARTICIPANTS:
        let res = db.bestUpdates.delStmt.exec(period.int64)
        res.expect("SQL query OK")
      else:
        let
          forkDigest = forkDigestAtEpoch(
            db.forkDigests, epoch(forkyUpdate.attested_header.beacon.slot), db.cfg
          )
          encodedUpdate = encodeForkedLightClientObject(update, forkDigest)
          res = db.bestUpdates.putStmt.exec((period.int64, encodedUpdate))
        res.expect("SQL query OK")
    else:
      db.bestUpdates.delStmt.exec(period.int64).expect("SQL query OK")

proc putUpdateIfBetter*(
    db: BeaconDb, period: SyncCommitteePeriod, update: ForkedLightClientUpdate
) =
  let currentUpdate = db.getBestUpdate(period).valueOr:
    # No current update for that period so we can just put this one
    db.putBestUpdate(period, update)
    return

  if is_better_update(update, currentUpdate):
    db.putBestUpdate(period, update)

proc putUpdateIfBetter*(db: BeaconDb, period: SyncCommitteePeriod, update: seq[byte]) =
  let newUpdate = decodeLightClientUpdateForked(db.forkDigests, update).valueOr:
    # TODO:
    # Need to go over the usage in offer/accept vs findcontent/content
    # and in some (all?) decoding has already been verified.
    return

  db.putUpdateIfBetter(period, newUpdate)

proc getLastFinalityUpdate*(db: BeaconDb): Opt[ForkedLightClientFinalityUpdate] =
  db.finalityUpdateCache.map(
    proc(x: LightClientFinalityUpdateCache): ForkedLightClientFinalityUpdate =
      decodeLightClientFinalityUpdateForked(db.forkDigests, x.lastFinalityUpdate).valueOr:
        raiseAssert "Stored finality update must be valid"
  )

func keepUpdatesFrom*(db: BeaconDb, minPeriod: SyncCommitteePeriod) =
  let res = db.bestUpdates.keepFromStmt.exec(minPeriod.int64)
  res.expect("SQL query OK")

func keepBootstrapsFrom*(db: BeaconDb, minSlot: Slot) =
  let res = db.bootstraps.keepFromStmt.exec(minSlot.int64)
  res.expect("SQL query OK")

proc createGetHandler*(db: BeaconDb): DbGetHandler =
  return (
    proc(contentKey: ContentKeyByteList, contentId: ContentId): results.Opt[seq[byte]] =
      let contentKey = contentKey.decode().valueOr:
        # TODO: as this should not fail, maybe it is better to raiseAssert ?
        return Opt.none(seq[byte])

      case contentKey.contentType
      of unused:
        raiseAssert "Should not be used and fail at decoding"
      of lightClientBootstrap:
        db.getBootstrap(contentId)
      of lightClientUpdate:
        let
          # TODO: add validation that startPeriod is not from the future,
          # this requires db to be aware off the current beacon time
          startPeriod = contentKey.lightClientUpdateKey.startPeriod
          # get max 128 updates
          numOfUpdates = min(
            uint64(MAX_REQUEST_LIGHT_CLIENT_UPDATES),
            contentKey.lightClientUpdateKey.count,
          )
          toPeriod = startPeriod + numOfUpdates # Not inclusive
          updates = db.getLightClientUpdates(startPeriod, toPeriod)

        if len(updates) == 0:
          Opt.none(seq[byte])
        else:
          # Note that this might not return all of the requested updates.
          # This might seem faulty/tricky as it is also used in handleOffer to
          # check if an offer should be accepted.
          # But it is actually fine as this will occur only when the node is
          # synced and it would not be able to verify the older updates in the
          # range anyhow.
          Opt.some(SSZ.encode(updates))
      of lightClientFinalityUpdate:
        # TODO:
        # Return only when the update is better than what is requested by
        # contentKey. This is currently not possible as the contentKey does not
        # include best update information.
        if db.finalityUpdateCache.isSome():
          let slot = contentKey.lightClientFinalityUpdateKey.finalizedSlot
          let cache = db.finalityUpdateCache.get()
          if cache.lastFinalityUpdateSlot >= slot:
            Opt.some(cache.lastFinalityUpdate)
          else:
            Opt.none(seq[byte])
        else:
          Opt.none(seq[byte])
      of lightClientOptimisticUpdate:
        # TODO same as above applies here too.
        if db.optimisticUpdateCache.isSome():
          let slot = contentKey.lightClientOptimisticUpdateKey.optimisticSlot
          let cache = db.optimisticUpdateCache.get()
          if cache.lastOptimisticUpdateSlot >= slot:
            Opt.some(cache.lastOptimisticUpdate)
          else:
            Opt.none(seq[byte])
        else:
          Opt.none(seq[byte])
      of beacon_content.ContentType.historicalSummaries:
        db.get(contentId)
  )

proc createStoreHandler*(db: BeaconDb): DbStoreHandler =
  return (
    proc(
        contentKey: ContentKeyByteList, contentId: ContentId, content: seq[byte]
    ) {.raises: [], gcsafe.} =
      let contentKey = decode(contentKey).valueOr:
        # TODO: as this should not fail, maybe it is better to raiseAssert ?
        return

      case contentKey.contentType
      of unused:
        raiseAssert "Should not be used and fail at decoding"
      of lightClientBootstrap:
        let bootstrap = decodeLightClientBootstrapForked(db.forkDigests, content).valueOr:
          return

        withForkyObject(bootstrap):
          when lcDataFork > LightClientDataFork.None:
            db.putBootstrap(contentId, content, forkyObject.header.beacon.slot)
      of lightClientUpdate:
        let updates = decodeSsz(content, ForkedLightClientUpdateBytesList).valueOr:
          return

        # Lot of assumptions here:
        # - that updates are continious i.e there is no period gaps
        # - that updates start from startPeriod of content key
        var period = contentKey.lightClientUpdateKey.startPeriod
        for update in updates.asSeq():
          # Only put the update if it is better, although in currently a new offer
          # should not be accepted as it is based on only the period.
          db.putUpdateIfBetter(SyncCommitteePeriod(period), update.asSeq())
          inc period
      of lightClientFinalityUpdate:
        db.finalityUpdateCache = Opt.some(
          LightClientFinalityUpdateCache(
            lastFinalityUpdateSlot:
              contentKey.lightClientFinalityUpdateKey.finalizedSlot,
            lastFinalityUpdate: content,
          )
        )
      of lightClientOptimisticUpdate:
        db.optimisticUpdateCache = Opt.some(
          LightClientOptimisticUpdateCache(
            lastOptimisticUpdateSlot:
              contentKey.lightClientOptimisticUpdateKey.optimisticSlot,
            lastOptimisticUpdate: content,
          )
        )
      of beacon_content.ContentType.historicalSummaries:
        # TODO: Its probably better to not use the kvstore here and instead use a sql
        # table with slot as index and move the slot logic to the db store handler.
        let current = db.get(contentId)
        if current.isSome():
          let summariesWithProof = decodeSsz(
            db.forkDigests, current.get(), HistoricalSummariesWithProof
          ).valueOr:
            raiseAssert error
          let newSummariesWithProof = decodeSsz(
            db.forkDigests, content, HistoricalSummariesWithProof
          ).valueOr:
            return
          if newSummariesWithProof.epoch > summariesWithProof.epoch:
            db.put(contentId, content)
        else:
          db.put(contentId, content)
  )

proc createRadiusHandler*(db: BeaconDb): DbRadiusHandler =
  return (
    proc(): UInt256 {.raises: [], gcsafe.} =
      db.dataRadius
  )