/**
 * This module handles chunk writer logic.
 */
"use strict";

const path = require("node:path");
const {
  DEFAULT_DAILY_CHUNK_CAP_BYTES,
  MAX_DAILY_CHUNK_COUNT,
  CHUNK_ID_WIDTH,
} = require("./constants");
const { UTF8_ENCODING } = require("../shared/constants");
const { DAILY_META_FILE_NAME } = require("./vault-paths");
const {
  assertNonEmptyString,
  assertNonNegativeInteger,
  assertPositiveInteger,
  isPlainObject,
  assertSyncFsApi,
} = require("../shared/assert");
const {
  assertDailyPathArgs,
  buildDailyFolderPath,
  buildDailyChunkPath,
  buildDailyIndexPath,
  buildChunkHeader,
  buildChunkIndexContent,
} = require("./vault");

const CHUNK_ID_PATTERN = new RegExp(`^\\d{${CHUNK_ID_WIDTH}}$`);
const CHUNK_FILE_PATTERN = new RegExp(`^\\d{${CHUNK_ID_WIDTH}}\\.md$`);
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

const assertMetaShape = (meta) => {
  if (!isPlainObject(meta) || !Array.isArray(meta.chunks)) {
    throw new Error("meta.json must contain an object with chunks array");
  }
  return meta;
};

const validateChunk = (chunk) => {
  const safeChunk = isPlainObject(chunk) ? chunk : {};
  const { id, file, sizeBytes, createdAt } = safeChunk;

  if (!CHUNK_ID_PATTERN.test(id)) {
    throw new Error("chunk id must match 3-digit format");
  }

  if (!CHUNK_FILE_PATTERN.test(file)) {
    throw new Error("chunk file must match 3-digit .md format");
  }

  if (file !== `${id}.md`) {
    throw new Error("chunk file must match chunk id");
  }

  if (!Number.isInteger(sizeBytes) || sizeBytes < 0) {
    throw new Error("chunk sizeBytes must be a non-negative integer");
  }

  if (typeof createdAt !== "string" || !ISO_TIMESTAMP_PATTERN.test(createdAt)) {
    throw new Error("chunk createdAt must be a valid ISO timestamp");
  }

  return {
    id,
    file,
    sizeBytes,
    createdAt,
  };
};

const validateContiguousChunkIds = (chunks) => {
  const isContiguous = chunks.every(
    (chunk, index) => chunk.id === String(index + 1).padStart(CHUNK_ID_WIDTH, "0"),
  );
  if (!isContiguous) {
    throw new Error("chunk ids must be contiguous starting at 001");
  }
};

const validateMeta = (meta) => {
  const safeMeta = assertMetaShape(meta);
  const validatedChunks = safeMeta.chunks.map((chunk) => validateChunk(chunk));
  validateContiguousChunkIds(validatedChunks);
  return {
    chunks: validatedChunks.map((chunk) => ({ ...chunk })),
  };
};

const loadMeta = (folderPath, fsApi = require("node:fs")) => {
  const safeFolderPath = assertNonEmptyString("folderPath", folderPath);
  const metaPath = path.join(safeFolderPath, DAILY_META_FILE_NAME);
  const rawMeta = (() => {
    try {
      return fsApi.readFileSync(metaPath, UTF8_ENCODING);
    } catch (error) {
      if (error !== null && typeof error === "object" && error.code === "ENOENT") {
        return null;
      }
      throw error;
    }
  })();

  if (rawMeta === null) {
    return { chunks: [] };
  }

  const parsedMeta = (() => {
    try {
      return JSON.parse(rawMeta);
    } catch (error) {
      if (error instanceof SyntaxError) {
        return null;
      }
      throw error;
    }
  })();

  if (parsedMeta === null) {
    return { chunks: [] };
  }

  return validateMeta(parsedMeta);
};

const saveMeta = (folderPath, meta, fsApi = require("node:fs")) => {
  const safeFolderPath = assertNonEmptyString("folderPath", folderPath);
  const safeMeta = validateMeta(meta);
  fsApi.mkdirSync(safeFolderPath, { recursive: true });
  fsApi.writeFileSync(
    path.join(safeFolderPath, DAILY_META_FILE_NAME),
    JSON.stringify(safeMeta, null, 2),
    UTF8_ENCODING,
  );
};

const getCurrentChunk = (meta) => {
  const safeMeta = assertMetaShape(meta);
  if (safeMeta.chunks.length === 0) {
    return null;
  }
  return safeMeta.chunks[safeMeta.chunks.length - 1];
};

const needsNewChunk = (currentChunk, contentByteLength, capBytes) => {
  const safeContentByteLength = assertNonNegativeInteger("contentByteLength", contentByteLength);
  const safeCapBytes = assertPositiveInteger("capBytes", capBytes);
  if (currentChunk === null) {
    return true;
  }
  return currentChunk.sizeBytes + safeContentByteLength > safeCapBytes;
};

const nextChunkNum = (meta) => {
  const safeMeta = assertMetaShape(meta);
  if (safeMeta.chunks.length >= MAX_DAILY_CHUNK_COUNT) {
    throw new Error("chunk count exceeds 999");
  }
  return safeMeta.chunks.length + 1;
};

const buildChunkEntry = (chunkNum, sizeBytes) => {
  if (!Number.isInteger(chunkNum) || chunkNum < 1 || chunkNum > MAX_DAILY_CHUNK_COUNT) {
    throw new Error("chunkNum must be an integer from 1 to 999");
  }

  const safeSizeBytes = assertNonNegativeInteger("sizeBytes", sizeBytes);
  const id = String(chunkNum).padStart(CHUNK_ID_WIDTH, "0");
  return {
    id,
    file: `${id}.md`,
    sizeBytes: safeSizeBytes,
    createdAt: new Date().toISOString(),
  };
};

const ensureDailyFolder = (fsApi, folderPath) => {
  if (fsApi.existsSync(folderPath)) {
    if (!fsApi.statSync(folderPath).isDirectory()) {
      throw new Error(`daily folder path is not a directory: ${folderPath}`);
    }
    return true;
  }

  fsApi.mkdirSync(folderPath, { recursive: true });
  return false;
};

const assertChunkFolderMetaState = (fsApi, folderPath, folderExistedBefore, meta) => {
  if (!folderExistedBefore || meta.chunks.length !== 0) {
    return;
  }

  const filesInFolder = fsApi.readdirSync(folderPath);
  const hasChunkArtifacts = filesInFolder.some(
    (fileName) => CHUNK_FILE_PATTERN.test(fileName) || fileName === "index.md",
  );

  if (hasChunkArtifacts) {
    throw new Error(`meta.json missing for existing chunk folder: ${folderPath}`);
  }
};

const rotateToNewChunk = (vaultPath, subfolder, today, content, meta, fsApi) => {
  const chunkNum = nextChunkNum(meta);
  const chunkPath = buildDailyChunkPath(vaultPath, subfolder, today, chunkNum);

  if (fsApi.existsSync(chunkPath)) {
    throw new Error(`chunk file already exists: ${chunkPath}`);
  }

  const chunkHeader = buildChunkHeader(today, chunkNum);
  const chunkText = chunkHeader + content;
  const chunkSizeBytes = Buffer.byteLength(chunkText, UTF8_ENCODING);

  fsApi.writeFileSync(chunkPath, chunkText, UTF8_ENCODING);

  const newChunk = buildChunkEntry(chunkNum, chunkSizeBytes);
  return {
    chunks: meta.chunks.concat([newChunk]),
  };
};

const appendToExistingChunk = (
  vaultPath,
  subfolder,
  today,
  content,
  contentByteLength,
  currentChunk,
  meta,
  fsApi,
) => {
  const chunkNum = Number(currentChunk.id);
  const chunkPath = buildDailyChunkPath(vaultPath, subfolder, today, chunkNum);

  if (!fsApi.existsSync(chunkPath)) {
    throw new Error(`current chunk file is missing: ${chunkPath}`);
  }

  if (!fsApi.statSync(chunkPath).isFile()) {
    throw new Error(`current chunk path is not a file: ${chunkPath}`);
  }

  fsApi.appendFileSync(chunkPath, content, UTF8_ENCODING);

  const updatedChunk = {
    ...currentChunk,
    sizeBytes: currentChunk.sizeBytes + contentByteLength,
  };

  return {
    chunks: meta.chunks.slice(0, -1).concat([updatedChunk]),
  };
};

const appendToChunked = (vaultPath, subfolder, today, content, options = {}) => {
  const safeToday = assertNonEmptyString("today", today);
  const { safeVaultPath, safeSubfolder } = assertDailyPathArgs(vaultPath, subfolder, safeToday);

  if (typeof content !== "string") {
    throw new Error("content must be a string");
  }

  const safeOptions = options !== null && typeof options === "object" ? options : {};
  const capBytes =
    typeof safeOptions.capBytes === "undefined"
      ? DEFAULT_DAILY_CHUNK_CAP_BYTES
      : safeOptions.capBytes;
  const fsApi = typeof safeOptions.fsApi === "undefined" ? require("node:fs") : safeOptions.fsApi;
  const safeCapBytes = assertPositiveInteger("capBytes", capBytes);
  const safeFsApi = assertSyncFsApi(fsApi, [
    "existsSync",
    "statSync",
    "readdirSync",
    "mkdirSync",
    "readFileSync",
    "writeFileSync",
    "appendFileSync",
  ]);

  const folderPath = buildDailyFolderPath(safeVaultPath, safeSubfolder, safeToday);
  const indexPath = buildDailyIndexPath(safeVaultPath, safeSubfolder, safeToday);

  const folderExistedBefore = ensureDailyFolder(safeFsApi, folderPath);

  const meta = loadMeta(folderPath, safeFsApi);
  assertChunkFolderMetaState(safeFsApi, folderPath, folderExistedBefore, meta);

  const currentChunk = getCurrentChunk(meta);
  const contentByteLength = Buffer.byteLength(content, UTF8_ENCODING);
  const rotate = needsNewChunk(currentChunk, contentByteLength, safeCapBytes);

  const nextMeta = rotate
    ? rotateToNewChunk(safeVaultPath, safeSubfolder, safeToday, content, meta, safeFsApi)
    : appendToExistingChunk(
        safeVaultPath,
        safeSubfolder,
        safeToday,
        content,
        contentByteLength,
        currentChunk,
        meta,
        safeFsApi,
      );

  saveMeta(folderPath, nextMeta, safeFsApi);

  const indexContent = buildChunkIndexContent(safeToday, nextMeta.chunks.length);
  safeFsApi.writeFileSync(indexPath, indexContent, UTF8_ENCODING);

  return undefined;
};

module.exports = {
  loadMeta,
  saveMeta,
  getCurrentChunk,
  needsNewChunk,
  nextChunkNum,
  buildChunkEntry,
  appendToChunked,
};
