/*
 This file is part of GNU Taler
 (C) 2024 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

import stream from "node:stream";
import {
  BindParams,
  ResultRow,
  RunResult,
  Sqlite3Database,
  Sqlite3Interface,
  Sqlite3Statement,
} from "./sqlite3-interface.js";

import child_process, { ChildProcessByStdio } from "node:child_process";
import { openPromise } from "./util/openPromise.js";

enum HelperCmd {
  HELLO = 1,
  SHUTDOWN = 2,
  OPEN = 3,
  CLOSE = 4,
  PREPARE = 5,
  STMT_GET_ALL = 6,
  STMT_GET_FIRST = 7,
  STMT_RUN = 8,
  EXEC = 9,
}

enum HelperResp {
  OK = 1,
  FAIL = 2,
  ROWLIST = 3,
  RUNRESULT = 4,
  STMT = 5,
}

function concatArr(as: Uint8Array[]): Uint8Array {
  let len = 0;
  for (const a of as) {
    len += a.length;
  }
  const b = new Uint8Array(len);
  let pos = 0;
  for (const a of as) {
    b.set(a, pos);
    pos += a.length;
  }
  return b;
}

interface ReqInfo {
  resolve: (x: Uint8Array) => void;
}

class Helper {
  private reqCounter = 0;
  private reqMap: Map<number, ReqInfo> = new Map();
  private inChunks: Uint8Array[] = [];
  private inSize: number = 0;
  private expectSize: number = 0;
  private enableTracing: boolean;
  private isListening: boolean = false;
  public proc: ChildProcessByStdio<stream.Writable, stream.Readable, null>;
  private promStarted: Promise<void>;

  constructor(opts?: { enableTracing: boolean }) {
    this.enableTracing = opts?.enableTracing ?? false;
    this.proc = child_process.spawn("taler-helper-sqlite3", {
      stdio: ["pipe", "pipe", "inherit"],
    });
    const startedPromcap = openPromise<void>();
    this.promStarted = startedPromcap.promise;
    this.proc.on("error", (err: Error) => {
      startedPromcap.reject(err);
    });
    this.proc.on("spawn", () => {
      startedPromcap.resolve();
    });
    // Make sure that the process is not blocking the parent process
    // from exiting.
    // When we are actively waiting for a response, we ref it again.
    this.unrefProc();
  }

  private unrefProc() {
    this.proc.unref();
    try {
      // @ts-ignore
      this.proc.stdout.unref();
    } catch (e) {
      // Do nothing.
    }
  }

  private refProc() {
    this.proc.ref();
    try {
      // @ts-ignore
      this.proc.stdout.ref();
    } catch (e) {
      // Do nothing.
    }
  }

  startListening() {
    if (this.isListening) {
      console.error("Warning: Already listening");
      return;
    }
    if (this.enableTracing) {
      console.error("starting listening for data");
    }
    this.refProc();
    this.proc.stdout.on("data", (chunk: Uint8Array) => {
      if (this.enableTracing) {
        console.error(`received chunk of size ${chunk.length} from helper`);
      }
      this.inChunks.push(chunk);
      this.inSize += chunk.length;

      while (true) {
        if (this.expectSize === 0) {
          if (this.inSize >= 4) {
            const data = concatArr(this.inChunks);
            const dv = new DataView(data.buffer);
            const len = dv.getUint32(0);
            this.expectSize = len;
            continue;
          }
        }

        if (this.expectSize > 0 && this.inSize >= this.expectSize) {
          const data = concatArr(this.inChunks);
          const packet = data.slice(0, this.expectSize);
          const rest = data.slice(this.expectSize);
          this.inSize = this.inSize - packet.length;
          this.inChunks = [rest];
          this.expectSize = 0;
          this.processResponse(packet);
          continue;
        }

        break;
      }
    });
    this.isListening = true;
  }

  processResponse(packet: Uint8Array): void {
    const dv = new DataView(packet.buffer);
    const reqId = dv.getUint32(4);
    if (this.enableTracing) {
      console.error(
        `processing complete response packet to ${reqId} from helper`,
      );
    }
    const ri = this.reqMap.get(reqId);
    if (!ri) {
      console.error(`no request for response with ID ${reqId}`);
      return;
    }
    this.reqMap.delete(reqId);
    ri.resolve(packet.slice(8));
  }

  async communicate(cmd: number, payload: Uint8Array): Promise<Uint8Array> {
    await this.promStarted;
    if (!this.isListening) {
      this.startListening();
    }

    const prom = openPromise<Uint8Array>();
    const reqNum = ++this.reqCounter;
    this.reqMap.set(reqNum, {
      resolve: prom.resolve,
    });
    // len, reqId, reqType, payload
    const bufLen = 4 + 4 + 1 + payload.length;
    const buf = new Uint8Array(bufLen);
    const dv = new DataView(buf.buffer);
    dv.setUint32(0, bufLen);
    dv.setUint32(4, reqNum);
    dv.setUint8(8, cmd);
    buf.set(payload, 9);

    await new Promise<void>((resolve, reject) => {
      if (this.enableTracing) {
        console.error(`writing to helper stdin for request ${reqNum}`);
      }
      this.proc.stdin.write(buf, (err) => {
        if (this.enableTracing) {
          console.error(`done writing to helper stdin for request ${reqNum}`);
        }
        if (err) {
          reject(err);
          return;
        }
        resolve();
      });
    });
    const resp = await prom.promise;
    if (this.enableTracing) {
      console.error(
        `request to ${reqNum} got result, reqMap keys ${[
          ...this.reqMap.keys(),
        ]}`,
      );
    }
    if (this.reqMap.size === 0) {
      this.isListening = false;
      this.proc.stdout.removeAllListeners();
      this.unrefProc();
    }
    return resp;
  }
}

enum TypeTag {
  NULL = 1,
  INT = 2,
  REAL = 3,
  TEXT = 4,
  BLOB = 5,
}

function encodeParams(wr: Writer, params: BindParams | undefined): void {
  const keys = Object.keys(params ?? {});
  wr.writeUint16(keys.length);
  for (const key of keys) {
    wr.writeString(key);
    const val = params![key];
    if (typeof val === "number" || typeof val === "bigint") {
      wr.writeUint8(TypeTag.INT);
      wr.writeInt64(BigInt(val));
    } else if (val == null) {
      wr.writeUint8(TypeTag.NULL);
    } else if (typeof val === "string") {
      wr.writeUint8(TypeTag.TEXT);
      wr.writeString(val);
    } else if (ArrayBuffer.isView(val)) {
      wr.writeUint8(TypeTag.BLOB);
      wr.writeUint32(val.length);
      wr.writeRawBytes(val);
    } else {
      throw Error("unsupported type for bind params");
    }
  }
}

function decodeRowList(rd: Reader): ResultRow[] {
  const rows: ResultRow[] = [];
  const numRows = rd.readUint16();
  const numCols = rd.readUint16();
  const colNames: string[] = [];
  for (let i = 0; i < numCols; i++) {
    colNames.push(rd.readString());
  }
  for (let i = 0; i < numRows; i++) {
    const row: ResultRow = {};
    for (let j = 0; j < numCols; j++) {
      const valTag = rd.readUint8();
      if (valTag === TypeTag.NULL) {
        row[colNames[j]] = null;
      } else if (valTag == TypeTag.TEXT) {
        row[colNames[j]] = rd.readString();
      } else if (valTag == TypeTag.BLOB) {
        row[colNames[j]] = rd.readBytes();
      } else if (valTag == TypeTag.INT) {
        let val: number | bigint = rd.readInt64();
        if (val <= Number.MAX_SAFE_INTEGER && val >= Number.MIN_SAFE_INTEGER) {
          val = Number(val);
        }
        row[colNames[j]] = val;
      }
    }
    rows.push(row);
  }
  return rows;
}

class Reader {
  public pos = 0;
  private dv: DataView;
  private td = new TextDecoder();
  constructor(private buf: Uint8Array) {
    this.dv = new DataView(buf.buffer);
  }
  readUint16(): number {
    const res = this.dv.getUint16(this.pos);
    this.pos += 2;
    return res;
  }
  readInt64(): bigint {
    const res = this.dv.getBigInt64(this.pos);
    this.pos += 8;
    return res;
  }
  readUint8(): number {
    const res = this.dv.getUint8(this.pos);
    this.pos += 1;
    return res;
  }
  readString(): string {
    const len = this.dv.getUint32(this.pos);
    const strBuf = this.buf.slice(this.pos + 4, this.pos + 4 + len);
    this.pos += 4 + len;
    return this.td.decode(strBuf);
  }
  readBytes(): Uint8Array {
    const len = this.dv.getUint32(this.pos);
    const rBuf = this.buf.slice(this.pos + 4, this.pos + 4 + len);
    this.pos += 4 + len;
    return rBuf;
  }
}

class Writer {
  private chunks: Uint8Array[] = [];

  private te = new TextEncoder();

  /**
   * Write raw bytes without any length-prefix.
   */
  writeRawBytes(b: Uint8Array): void {
    this.chunks.push(b);
  }

  /**
   * Write length-prefixed string.
   */
  writeString(s: string) {
    const bufStr = this.te.encode(s);
    this.writeUint32(bufStr.length);
    this.chunks.push(bufStr);
  }

  writeUint8(n: number): void {
    const buf = new Uint8Array(1);
    const dv = new DataView(buf.buffer);
    dv.setUint8(0, n);
    this.chunks.push(buf);
  }

  writeUint16(n: number): void {
    const buf = new Uint8Array(2);
    const dv = new DataView(buf.buffer);
    dv.setUint16(0, n);
    this.chunks.push(buf);
  }

  writeUint32(n: number): void {
    const buf = new Uint8Array(4);
    const dv = new DataView(buf.buffer);
    dv.setUint32(0, n);
    this.chunks.push(buf);
  }

  writeInt64(n: bigint): void {
    const buf = new Uint8Array(8);
    const dv = new DataView(buf.buffer);
    dv.setBigInt64(0, n);
    this.chunks.push(buf);
  }

  reap(): Uint8Array {
    return concatArr(this.chunks);
  }
}

class Sqlite3Error extends Error {
  // Name of "code" is to be compatible with better-sqlite3.
  constructor(
    message: string,
    public code: string,
  ) {
    super(message);
  }
}

function throwForFailure(rd: Reader): never {
  const msg = rd.readString();
  // Numeric error code
  rd.readUint16();
  const errName = rd.readString();
  throw new Sqlite3Error(msg, errName);
}

function expectCommunicateSuccess(commRes: Uint8Array): void {
  const rd = new Reader(commRes);
  const respType = rd.readUint8();
  if (respType == HelperResp.OK) {
    // Good
  } else if (respType == HelperResp.FAIL) {
    throwForFailure(rd);
  } else {
    throw Error("unexpected response tag");
  }
}

export async function createNodeHelperSqlite3Impl(
  opts: { enableTracing?: boolean } = {},
): Promise<Sqlite3Interface> {
  const enableTracing = opts.enableTracing ?? false;
  const helper = new Helper({ enableTracing });
  const resp = await helper.communicate(HelperCmd.HELLO, new Uint8Array());

  let counterDb = 1;
  let counterPrep = 1;

  return {
    async open(filename: string): Promise<Sqlite3Database> {
      if (enableTracing) {
        console.error(`opening database ${filename}`);
      }
      const myDbId = counterDb++;
      {
        const wr = new Writer();
        wr.writeUint16(myDbId);
        wr.writeString(filename);
        const payload = wr.reap();
        const commRes = await helper.communicate(HelperCmd.OPEN, payload);
        expectCommunicateSuccess(commRes);
      }
      if (enableTracing) {
        console.error(`opened database ${filename}`);
      }
      return {
        internalDbHandle: undefined,
        async close() {
          if (enableTracing) {
            console.error(`closing database`);
          }
          const wr = new Writer();
          wr.writeUint16(myDbId);
          const payload = wr.reap();
          const commRes = await helper.communicate(HelperCmd.CLOSE, payload);
          expectCommunicateSuccess(commRes);
        },
        async prepare(stmtStr): Promise<Sqlite3Statement> {
          const myPrepId = counterPrep++;
          if (enableTracing) {
            console.error(`preparing statement ${myPrepId}`);
          }
          {
            const wr = new Writer();
            wr.writeUint16(myDbId);
            wr.writeUint16(myPrepId);
            wr.writeString(stmtStr);
            const payload = wr.reap();
            const commRes = await helper.communicate(
              HelperCmd.PREPARE,
              payload,
            );
            expectCommunicateSuccess(commRes);
          }
          if (enableTracing) {
            console.error(`prepared statement ${myPrepId}`);
          }
          return {
            internalStatement: undefined,
            async getAll(params?: BindParams): Promise<ResultRow[]> {
              if (enableTracing) {
                console.error(`running getAll`);
              }
              const wr = new Writer();
              wr.writeUint16(myPrepId);
              encodeParams(wr, params);
              const payload = wr.reap();
              const commRes = await helper.communicate(
                HelperCmd.STMT_GET_ALL,
                payload,
              );
              const rd = new Reader(commRes);
              const respType = rd.readUint8();
              if (respType === HelperResp.ROWLIST) {
                const rows = decodeRowList(rd);
                return rows;
              } else if (respType === HelperResp.FAIL) {
                throwForFailure(rd);
              } else {
                throw Error("unexpected result for getAll");
              }
            },
            async getFirst(
              params?: BindParams,
            ): Promise<ResultRow | undefined> {
              if (enableTracing) {
                console.error(`running getFirst`);
              }
              const wr = new Writer();
              wr.writeUint16(myPrepId);
              encodeParams(wr, params);
              const payload = wr.reap();
              const commRes = await helper.communicate(
                HelperCmd.STMT_GET_FIRST,
                payload,
              );
              const rd = new Reader(commRes);
              const respType = rd.readUint8();
              if (respType === HelperResp.ROWLIST) {
                const rows = decodeRowList(rd);
                return rows[0];
              } else if (respType === HelperResp.FAIL) {
                throwForFailure(rd);
              } else {
                throw Error("unexpected result for getAll");
              }
            },
            async run(params?: BindParams): Promise<RunResult> {
              if (enableTracing) {
                console.error(`running run`);
              }
              const wr = new Writer();
              wr.writeUint16(myPrepId);
              encodeParams(wr, params);
              const payload = wr.reap();
              const commRes = await helper.communicate(
                HelperCmd.STMT_RUN,
                payload,
              );
              if (enableTracing) {
                console.error(`run got response`);
              }
              const rd = new Reader(commRes);
              const respType = rd.readUint8();
              if (respType === HelperResp.OK) {
                if (enableTracing) {
                  console.error(`run success (OK)`);
                }
                return {
                  lastInsertRowid: 0,
                };
              } else if (respType === HelperResp.RUNRESULT) {
                if (enableTracing) {
                  console.error(`run success (RUNRESULT)`);
                }
                const lastInsertRowid = rd.readInt64();
                return {
                  lastInsertRowid,
                };
              } else if (respType === HelperResp.FAIL) {
                if (enableTracing) {
                  console.error(`run error (FAIL)`);
                }
                throwForFailure(rd);
              } else {
                throw Error("SQL run failed");
              }
            },
          };
        },
        async exec(sqlStr: string): Promise<void> {
          {
            if (enableTracing) {
              console.error(`running execute`);
            }
            const wr = new Writer();
            wr.writeUint16(myDbId);
            wr.writeString(sqlStr);
            const payload = wr.reap();
            const execRes = await helper.communicate(HelperCmd.EXEC, payload);
            expectCommunicateSuccess(execRes);
          }
        },
      };
    },
  };
}
