import {ClassicLevel} from "classic-level";
import {Logger} from "@lodestar/utils";
import {DbReqOpts, DatabaseController, DatabaseOptions, FilterOptions, KeyValue} from "./interface.js";
import {LevelDbControllerMetrics} from "./metrics.js";

enum Status {
  started = "started",
  closed = "closed",
}

export interface LevelDBOptions extends DatabaseOptions {
  db?: ClassicLevel<Uint8Array, Uint8Array>;
}

export type LevelDbControllerModules = {
  logger: Logger;
  metrics?: LevelDbControllerMetrics | null;
};

const BUCKET_ID_UNKNOWN = "unknown";

/** Time between capturing metric for db size, every few minutes is sufficient */
const DB_SIZE_METRIC_INTERVAL_MS = 5 * 60 * 1000;

/**
 * The LevelDB implementation of DB
 */
export class LevelDbController implements DatabaseController<Uint8Array, Uint8Array> {
  private status = Status.started;

  private dbSizeMetricInterval?: NodeJS.Timeout;

  constructor(
    private readonly logger: Logger,
    private readonly db: ClassicLevel<Uint8Array, Uint8Array>,
    private metrics: LevelDbControllerMetrics | null
  ) {
    this.metrics = metrics ?? null;

    if (this.metrics) {
      this.collectDbSizeMetric();
    }
  }

  static async create(opts: LevelDBOptions, {metrics, logger}: LevelDbControllerModules): Promise<LevelDbController> {
    const db =
      opts.db ||
      new ClassicLevel(opts.name || "beaconchain", {
        keyEncoding: "binary",
        valueEncoding: "binary",
        multithreading: true,
      });

    try {
      await db.open();
    } catch (e) {
      if ((e as LevelDbError).cause?.code === "LEVEL_LOCKED") {
        throw new Error("Database already in use by another process");
      }
      throw e;
    }

    return new LevelDbController(logger, db, metrics ?? null);
  }

  async close(): Promise<void> {
    if (this.status === Status.closed) return;
    this.status = Status.closed;

    if (this.dbSizeMetricInterval) {
      clearInterval(this.dbSizeMetricInterval);
    }

    await this.db.close();
  }

  /** To inject metrics after CLI initialization */
  setMetrics(metrics: LevelDbControllerMetrics): void {
    if (this.metrics !== null) {
      throw Error("metrics can only be set once");
    } else {
      this.metrics = metrics;
      if (this.status === Status.started) {
        this.collectDbSizeMetric();
      }
    }
  }

  async clear(): Promise<void> {
    await this.db.clear();
  }

  async get(key: Uint8Array, opts?: DbReqOpts): Promise<Uint8Array | null> {
    try {
      this.metrics?.dbReadReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);
      this.metrics?.dbReadItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);
      return (await this.db.get(key)) as Uint8Array | null;
    } catch (e) {
      if ((e as LevelDbError).code === "LEVEL_NOT_FOUND") {
        return null;
      }
      throw e;
    }
  }

  put(key: Uint8Array, value: Uint8Array, opts?: DbReqOpts): Promise<void> {
    this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);
    this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);

    return this.db.put(key, value);
  }

  delete(key: Uint8Array, opts?: DbReqOpts): Promise<void> {
    this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);
    this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);

    return this.db.del(key);
  }

  batchPut(items: KeyValue<Uint8Array, Uint8Array>[], opts?: DbReqOpts): Promise<void> {
    this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);
    this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, items.length);

    return this.db.batch(items.map((item) => ({type: "put", key: item.key, value: item.value})));
  }

  batchDelete(keys: Uint8Array[], opts?: DbReqOpts): Promise<void> {
    this.metrics?.dbWriteReq.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, 1);
    this.metrics?.dbWriteItems.inc({bucket: opts?.bucketId ?? BUCKET_ID_UNKNOWN}, keys.length);

    return this.db.batch(keys.map((key) => ({type: "del", key: key})));
  }

  keysStream(opts: FilterOptions<Uint8Array> = {}): AsyncIterable<Uint8Array> {
    return this.metricsIterator(this.db.keys(opts), (key) => key, opts.bucketId ?? BUCKET_ID_UNKNOWN);
  }

  valuesStream(opts: FilterOptions<Uint8Array> = {}): AsyncIterable<Uint8Array> {
    return this.metricsIterator(this.db.values(opts), (value) => value, opts.bucketId ?? BUCKET_ID_UNKNOWN);
  }

  entriesStream(opts: FilterOptions<Uint8Array> = {}): AsyncIterable<KeyValue<Uint8Array, Uint8Array>> {
    return this.metricsIterator(
      this.db.iterator(opts),
      (entry) => ({key: entry[0], value: entry[1]}),
      opts.bucketId ?? BUCKET_ID_UNKNOWN
    );
  }

  keys(opts: FilterOptions<Uint8Array> = {}): Promise<Uint8Array[]> {
    return this.metricsAll(this.db.keys(opts).all(), opts.bucketId ?? BUCKET_ID_UNKNOWN);
  }

  values(opts: FilterOptions<Uint8Array> = {}): Promise<Uint8Array[]> {
    return this.metricsAll(this.db.values(opts).all(), opts.bucketId ?? BUCKET_ID_UNKNOWN);
  }

  async entries(opts: FilterOptions<Uint8Array> = {}): Promise<KeyValue<Uint8Array, Uint8Array>[]> {
    const entries = await this.metricsAll(this.db.iterator(opts).all(), opts.bucketId ?? BUCKET_ID_UNKNOWN);
    return entries.map((entry) => ({key: entry[0], value: entry[1]}));
  }

  /**
   * Get the approximate number of bytes of file system space used by the range [start..end).
   * The result might not include recently written data.
   */
  approximateSize(start: Uint8Array, end: Uint8Array): Promise<number> {
    return this.db.approximateSize(start, end);
  }

  /**
   * Manually trigger a database compaction in the range [start..end].
   */
  compactRange(start: Uint8Array, end: Uint8Array): Promise<void> {
    return this.db.compactRange(start, end);
  }

  /** Capture metrics for db.iterator, db.keys, db.values .all() calls */
  private async metricsAll<T>(promise: Promise<T[]>, bucket: string): Promise<T[]> {
    this.metrics?.dbWriteReq.inc({bucket}, 1);
    const items = await promise;
    this.metrics?.dbWriteItems.inc({bucket}, items.length);
    return items;
  }

  /** Capture metrics for db.iterator, db.keys, db.values AsyncIterable calls */
  private async *metricsIterator<T, K>(
    iterator: AsyncIterable<T>,
    getValue: (item: T) => K,
    bucket: string
  ): AsyncIterable<K> {
    this.metrics?.dbWriteReq.inc({bucket}, 1);

    let itemsRead = 0;

    for await (const item of iterator) {
      // Count metrics after done condition
      itemsRead++;

      yield getValue(item);
    }

    this.metrics?.dbWriteItems.inc({bucket}, itemsRead);
  }

  /** Start interval to capture metric for db size */
  private collectDbSizeMetric(): void {
    this.dbSizeMetric();
    this.dbSizeMetricInterval = setInterval(this.dbSizeMetric.bind(this), DB_SIZE_METRIC_INTERVAL_MS);
  }

  /** Capture metric for db size */
  private dbSizeMetric(): void {
    const timer = this.metrics?.dbApproximateSizeTime.startTimer();
    const minKey = Buffer.from([0x00]);
    const maxKey = Buffer.from([0xff]);

    this.approximateSize(minKey, maxKey)
      .then((dbSize) => {
        this.metrics?.dbSizeTotal.set(dbSize);
      })
      .catch((e) => {
        this.logger.debug("Error approximating db size", {}, e);
      })
      .finally(timer);
  }

  static async destroy(location: string): Promise<void> {
    return ClassicLevel.destroy(location);
  }
}

/** From https://www.npmjs.com/package/level */
type LevelDbError = {code: "LEVEL_NOT_FOUND"; cause?: {code: "LEVEL_LOCKED"}};
