/*

Why?

Convenience re-export/re-implementation of most common fs functions from
node:fs, node:fs/promises and fs-extra

Defaults to string input/output, as it's used in 80% of time. For the rest - you can use native fs.

Allows to import it easier, so you don't have to choose between the 3 import locations.
That's why function names are slightly renamed, to avoid conflict.

Credit to: fs-extra (https://github.com/jprichardson/node-fs-extra)

 */

import type { RmOptions, Stats } from 'node:fs'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { _jsonParse } from '@naturalcycles/js-lib/string/json.util.js'

/**
 * fs2 conveniently groups filesystem functions together.
 * Supposed to be almost a drop-in replacement for these things together:
 *
 * 1. node:fs
 * 2. node:fs/promises
 * 3. fs-extra
 */
class FS2 {
  // Naming convention is:
  // - async function has Async in the name, e.g readTextAsync
  // - sync function has postfix in the name, e.g readText

  /**
   * Convenience wrapper that defaults to utf-8 string output.
   */
  readText(filePath: string): string {
    return fs.readFileSync(filePath, 'utf8')
  }

  /**
   * Convenience wrapper that defaults to utf-8 string output.
   */
  async readTextAsync(filePath: string): Promise<string> {
    return await fsp.readFile(filePath, 'utf8')
  }

  readBuffer(filePath: string): Buffer {
    return fs.readFileSync(filePath)
  }

  async readBufferAsync(filePath: string): Promise<Buffer> {
    return await fsp.readFile(filePath)
  }

  readJson<T = unknown>(filePath: string): T {
    const str = fs.readFileSync(filePath, 'utf8')
    return _jsonParse(str)
  }

  async readJsonAsync<T = unknown>(filePath: string): Promise<T> {
    const str = await fsp.readFile(filePath, 'utf8')
    return _jsonParse(str)
  }

  writeFile(filePath: string, data: string | Buffer): void {
    fs.writeFileSync(filePath, data)
  }

  async writeFileAsync(filePath: string, data: string | Buffer): Promise<void> {
    await fsp.writeFile(filePath, data)
  }

  writeJson(filePath: string, data: any, opt?: JsonOptions): void {
    const str = stringify(data, opt)
    fs.writeFileSync(filePath, str)
  }

  async writeJsonAsync(filePath: string, data: any, opt?: JsonOptions): Promise<void> {
    const str = stringify(data, opt)
    await fsp.writeFile(filePath, str)
  }

  appendFile(filePath: string, data: string | Buffer): void {
    fs.appendFileSync(filePath, data)
  }

  async appendFileAsync(filePath: string, data: string | Buffer): Promise<void> {
    await fsp.appendFile(filePath, data)
  }

  outputJson(filePath: string, data: any, opt?: JsonOptions): void {
    const str = stringify(data, opt)
    this.outputFile(filePath, str)
  }

  async outputJsonAsync(filePath: string, data: any, opt?: JsonOptions): Promise<void> {
    const str = stringify(data, opt)
    await this.outputFileAsync(filePath, str)
  }

  outputFile(filePath: string, data: string | Buffer): void {
    const dirPath = path.dirname(filePath)
    if (!fs.existsSync(dirPath)) {
      this.ensureDir(dirPath)
    }

    fs.writeFileSync(filePath, data)
  }

  async outputFileAsync(filePath: string, data: string | Buffer): Promise<void> {
    const dirPath = path.dirname(filePath)
    if (!(await this.pathExistsAsync(dirPath))) {
      await this.ensureDirAsync(dirPath)
    }

    await fsp.writeFile(filePath, data)
  }

  pathExists(filePath: string): boolean {
    return fs.existsSync(filePath)
  }

  async pathExistsAsync(filePath: string): Promise<boolean> {
    try {
      await fsp.access(filePath)
      return true
    } catch {
      return false
    }
  }

  requireFileToExist(filePath: string): void {
    if (!fs.existsSync(filePath)) {
      throw new Error(`Required file should exist: ${filePath}`)
    }
  }

  ensureDir(dirPath: string): void {
    fs.mkdirSync(dirPath, {
      mode: 0o777,
      recursive: true,
    })
  }

  async ensureDirAsync(dirPath: string): Promise<void> {
    await fsp.mkdir(dirPath, {
      mode: 0o777,
      recursive: true,
    })
  }

  ensureFile(filePath: string): void {
    let stats: Stats | undefined
    try {
      stats = fs.statSync(filePath)
    } catch {}
    if (stats?.isFile()) return

    const dir = path.dirname(filePath)
    try {
      if (!fs.statSync(dir).isDirectory()) {
        // parent is not a directory
        // This is just to cause an internal ENOTDIR error to be thrown
        fs.readdirSync(dir)
      }
    } catch (err) {
      // If the stat call above failed because the directory doesn't exist, create it
      if ((err as any)?.code === 'ENOENT') {
        this.ensureDir(dir)
        return
      }
      throw err
    }

    fs.writeFileSync(filePath, '')
  }

  async ensureFileAsync(filePath: string): Promise<void> {
    let stats: Stats | undefined
    try {
      stats = await fsp.stat(filePath)
    } catch {}
    if (stats?.isFile()) return

    const dir = path.dirname(filePath)
    try {
      if (!(await fsp.stat(dir)).isDirectory()) {
        // parent is not a directory
        // This is just to cause an internal ENOTDIR error to be thrown
        await fsp.readdir(dir)
      }
    } catch (err) {
      // If the stat call above failed because the directory doesn't exist, create it
      if ((err as any)?.code === 'ENOENT') return await this.ensureDirAsync(dir)
      throw err
    }

    await fsp.writeFile(filePath, '')
  }

  removePath(fileOrDirPath: string, opt?: RmOptions): void {
    fs.rmSync(fileOrDirPath, { recursive: true, force: true, ...opt })
  }

  async removePathAsync(fileOrDirPath: string, opt?: RmOptions): Promise<void> {
    await fsp.rm(fileOrDirPath, { recursive: true, force: true, ...opt })
  }

  emptyDir(dirPath: string): void {
    let items: string[]
    try {
      items = fs.readdirSync(dirPath)
    } catch {
      this.ensureDir(dirPath)
      return
    }

    for (const item of items) {
      this.removePath(path.join(dirPath, item))
    }
  }

  async emptyDirAsync(dirPath: string): Promise<void> {
    let items: string[]
    try {
      items = await fsp.readdir(dirPath)
    } catch {
      return await this.ensureDirAsync(dirPath)
    }

    await Promise.all(items.map(item => this.removePathAsync(path.join(dirPath, item))))
  }

  /**
   * Cautious, underlying Node function is currently Experimental.
   */
  copyPath(src: string, dest: string, opt?: fs.CopySyncOptions): void {
    fs.cpSync(src, dest, {
      recursive: true,
      ...opt,
    })
  }

  /**
   * Cautious, underlying Node function is currently Experimental.
   */
  async copyPathAsync(src: string, dest: string, opt?: fs.CopyOptions): Promise<void> {
    await fsp.cp(src, dest, {
      recursive: true,
      ...opt,
    })
  }

  renamePath(src: string, dest: string): void {
    fs.renameSync(src, dest)
  }

  async renamePathAsync(src: string, dest: string): Promise<void> {
    await fsp.rename(src, dest)
  }

  movePath(src: string, dest: string, opt?: fs.CopySyncOptions): void {
    this.copyPath(src, dest, opt)
    this.removePath(src)
  }

  async movePathAsync(src: string, dest: string, opt?: fs.CopyOptions): Promise<void> {
    await this.copyPathAsync(src, dest, opt)
    await this.removePathAsync(src)
  }

  /**
   * Returns true if the path is a directory.
   * Otherwise returns false.
   * Doesn't throw, returns false instead.
   */
  isDirectory(filePath: string): boolean {
    return (
      fs2
        .stat(filePath, {
          throwIfNoEntry: false,
        })
        ?.isDirectory() || false
    )
  }

  // Re-export the whole fs/fsp, for the edge cases where they are needed
  fs = fs
  fsp = fsp

  // Re-export existing fs/fsp functions
  // rm/rmAsync are replaced with removePath/removePathAsync
  lstat = fs.lstatSync
  lstatAsync = fsp.lstat
  stat = fs.statSync
  statAsync = fsp.stat
  mkdir = fs.mkdirSync
  mkdirAsync = fsp.mkdir
  readdir = fs.readdirSync
  readdirAsync = fsp.readdir
  createWriteStream = fs.createWriteStream
  createReadStream = fs.createReadStream
}

export const fs2 = new FS2()

export interface JsonOptions {
  spaces?: number
}

function stringify(data: any, opt?: JsonOptions): string {
  // If pretty-printing is enabled (spaces) - also add a newline at the end (to match our prettier config)
  return JSON.stringify(data, null, opt?.spaces) + (opt?.spaces ? '\n' : '')
}
