import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { BuildFailure, assert, assertPromises } from './asserts'
import { requireContext } from './async'
import { Files } from './files'
import { rm } from './fs'
import { $gry, $p, $plur, $wht, $ylw, log } from './logging'
import {
  commonPath,
  getAbsoluteParent,
  getCurrentWorkingDirectory,
  resolveDirectory,
  resolveFile,
} from './paths'
import { PipeImpl } from './pipe'
import { RunBuild } from './plugs/build'
import { JsoncError, parseJsonc } from './utils'
import { execChild } from './utils/exec'
import { parseOptions } from './utils/options'
import { walk } from './utils/walk'

import type { Pipe } from './index'
import type { AbsolutePath } from './paths'
import type { Context } from './pipe'
import type { RunBuildOptions } from './plugs/build'
import type { ExecChildOptions } from './utils/exec'
import type { ParseOptions } from './utils/options'
import type { WalkOptions } from './utils/walk'

/* ========================================================================== *
 * EXTERNAL HELPERS                                                           *
 * ========================================================================== */

/**
 * The {@link UsingOptions} interface defines the options for building
 * {@link Files} instances from a static list of paths.
 */
export interface UsingOptions {
  /**
   * The directory the {@link Files} instance will be rooted into (defaults to
   * the _current working directory_).
   */
  directory?: string
}

/** The {@link FindOptions} interface defines the options for finding files. */
export interface FindOptions extends WalkOptions {
  /** The directory where to start looking for files. */
  directory?: string
}

/** Return the current execution {@link Context} */
export function context(): Context {
  return requireContext()
}

/** Find files in the current directory using the specified _glob_. */
export function find(glob: string): Pipe
/** Find files in the current directory using the specified _globs_. */
export function find(glob: string, ...globs: string[]): Pipe
/** Find files using the specified _glob_ and {@link FindOptions | options}. */
export function find(glob: string, options: FindOptions): Pipe
/** Find files using the specified _globs_ and {@link FindOptions | options}. */
export function find(glob: string, ...extra: [...globs: string[], options: FindOptions]): Pipe
/* Overload */
export function find(...args: ParseOptions<FindOptions>): Pipe {
  const { params: globs, options } = parseOptions(args, {})

  const context = requireContext()
  return new PipeImpl(context, Promise.resolve().then(async () => {
    const directory = options.directory ?
      context.resolve(options.directory) :
      getCurrentWorkingDirectory()

    const builder = Files.builder(directory)
    for await (const file of walk(directory, globs, options)) {
      builder.add(file)
    }

    return builder.build()
  }))
}

export type InvokeBuildOptions = RunBuildOptions & Record<string, string>
export type InvokeBuildTasks = string | [ string, ...string[] ]

export function invokeBuild(buildFile: string): Promise<void>
export function invokeBuild(buildFile: string, task: string): Promise<void>
export function invokeBuild(buildFile: string, task: string, options: InvokeBuildOptions): Promise<void>
export function invokeBuild(buildFile: string, tasks: [ string, ...string[] ]): Promise<void>
export function invokeBuild(buildFile: string, tasks: [ string, ...string[] ], options: InvokeBuildOptions): Promise<void>
export function invokeBuild(buildFile: string, options: InvokeBuildOptions): Promise<void>
export async function invokeBuild(
    buildFile: string,
    tasksOrOptions?: string | [ string, ...string[] ] | InvokeBuildOptions,
    maybeOptions?: InvokeBuildOptions,
): Promise<void> {
  const [ tasks, options = {} ] =
    typeof tasksOrOptions === 'string' ?
      [ [ tasksOrOptions ], maybeOptions ] :
      Array.isArray(tasksOrOptions) ?
        [ tasksOrOptions, maybeOptions ] :
        typeof tasksOrOptions === 'object' ?
          [ [ 'default' ], tasksOrOptions ] :
          [ [ 'default' ], {} ]

  if (tasks.length === 0) tasks.push('default')

  const { coverageDir, forceModule, ...props } = options
  const forkOptions = { coverageDir, forceModule }

  const context = requireContext()
  const file = context.resolve(buildFile)
  const dir = getAbsoluteParent(file)
  const files = Files.builder(dir).add(file).build()

  return new RunBuild(tasks, props, forkOptions)
      .pipe(files, context)
      .then(() => void 0)
}

/**
 * Recursively remove the specified directory _**(use with care)**_.
 */
export async function rmrf(directory: string): Promise<void> {
  const context = requireContext()
  const dir = context.resolve(directory)

  assert(dir !== getCurrentWorkingDirectory(),
      `Cowardly refusing to wipe current working directory ${$p(dir)}`)

  assert(dir !== context.resolve('@'),
      `Cowardly refusing to wipe build file directory ${$p(dir)}`)

  /* coverage ignore if */
  if (! resolveDirectory(dir)) {
    log.info('Directory', $p(dir), 'not found')
    return
  }

  log.notice('Removing directory', $p(dir), 'recursively')
  await rm(dir, { recursive: true })
}

/**
 * Merge the results of several {@link Pipe}s into a single one.
 *
 * Merging is performed _in parallel_. When serial execution is to be desired,
 * we can merge the awaited _result_ of the {@link Pipe}.
 *
 * For example:
 *
 * ```
 * const pipe: Pipe = merge([
 *   await this.anotherTask1(),
 *   await this.anotherTask2(),
 * ])
 * ```
 */
export function merge(pipes: (Pipe | Files | Promise<Files>)[]): Pipe {
  const context = requireContext()
  return new PipeImpl(context, Promise.resolve().then(async () => {
    // No pipes? Just send off an empty pipe...
    if (pipes.length === 0) return new Files()

    // Await for all pipes / files / files promises
    const awaited = await assertPromises<Files>(pipes)
    const results = awaited.filter((result) => result.length)

    // No files in anything to be merged? Again send off an empty pipe...
    if (results.length === 0) return new Files()

    // Find the common directory between all the Files instances
    const [ firstDir, ...otherDirs ] = results.map((f) => f.directory)
    const directory = commonPath(firstDir!, ...otherDirs)

    // Build our new files instance merging all the results
    return Files.builder(directory).merge(...results).build()
  }))
}

/**
 * Create an empty _no-op_ {@link Pipe}.
 *
 * This is useful when creating tasks with conditional pipes and returning the
 * correct type, for example:
 *
 * ```
 * if (someCondition) {
 *   return find(...).pipe(...)
 * } else {
 *   return noop()
 * }
 * ```
 */
export function noop(): Pipe {
  const context = requireContext()
  return new PipeImpl(context, Promise.resolve(new Files()))
}

/**
 * Create a {@link Pipe} from a static set of files.
 *
 * The default directory where the {@link Files} instance will be rooted into
 * is the _current working directory_.
 *
 * If _relative_ each file specified will be resolved relatively to the base
 * {@link Files} directory.
 */
export function using(...args: [ ...string[] ]): Pipe
export function using(...args: [ ...string[], options: UsingOptions ]): Pipe
export function using(...args: ParseOptions<UsingOptions>): Pipe {
  const { options, params } = parseOptions(args, { directory: '.' })

  const context = requireContext()
  const directory = context.resolve(options.directory)

  const files = Files.builder(directory).add(...params).build()
  return new PipeImpl(context, Promise.resolve(files))
}

/**
 * Resolve a (set of) path(s) into an {@link AbsolutePath}.
 *
 * If the path (or first component thereof) starts with `@...`, then the
 * resolved path will be relative to the directory containing the build file
 * where the current task was defined, otherwise it will be relative to the
 * current working directory.
 */
export function resolve(...paths: [ string, ...string[] ]): AbsolutePath {
  return requireContext().resolve(...paths)
}

/**
 * Return an absolute path of the file if it exist on disk.
 *
 * See the comments on {@link resolve} to understand how paths are resolved.
 */
export function isFile(...paths: [ string, ...string[] ]): AbsolutePath | undefined {
  const path = requireContext().resolve(...paths)
  return resolveFile(path)
}

/**
 * Return an absolute path of the directory if it exist on disk.
 *
 * See the comments on {@link resolve} to understand how paths are resolved.
 */
export function isDirectory(...paths: [ string, ...string[] ]): AbsolutePath | undefined {
  const path = requireContext().resolve(...paths)
  return resolveDirectory(path)
}

/**
 * Create a temporary directory and return its {@link AbsolutePath}.
 *
 * The directory will be rooted in `/tmp` or wherever `os.tmpdir()` decides.
 */
export function mkdtemp(): AbsolutePath {
  const prefix = join(tmpdir(), 'plugjs-')
  const path = mkdtempSync(prefix)
  return resolve(path)
}

/**
 * Execute a command and await for its result from within a task.
 *
 * For example:
 *
 * ```
 * import { exec } from '@plugjs/plugjs'
 *
 * export default build({
 *   async runme() {
 *     await exec('ls', '-la', '/')
 *     // or similarly letting the shell interpret the command
 *     await exec('ls -la /', { shell: true })
 *   },
 * })
 * ```
 *
 * @param cmd The command to execute
 * @param args Any additional argument for the command to execute
 * @param options Extra {@link ExecChildOptions | options} for process execution
 */
export function exec(
    cmd: string,
    ...args: [ ...args: string[] ] | [ ...args: string[], options: ExecChildOptions ]
): Promise<void> {
  const { params, options } = parseOptions(args)
  return execChild(cmd, params, options, requireContext())
}

/**
 * Read and parse a JSON/JSONC file, throwing an error if not found.
 *
 * @params file The JSON file to parse
 */
export function parseJson(file: string, strict: boolean = false): any {
  const jsonFile = requireContext().resolve(file)
  let jsonText: string
  try {
    jsonText = readFileSync(jsonFile, 'utf-8')
  } catch (error: any) {
    if (error.code === 'ENOENT') log.fail(`File ${$p(jsonFile)} not found`)
    if (error.code === 'EACCES') log.fail(`File ${$p(jsonFile)} can not be accessed`)
    log.fail(`Error reading ${$p(jsonFile)}`, error)
  }

  try {
    return parseJsonc(jsonText, {
      disallowComments: strict,
      allowTrailingComma: ! strict,
    })
  } catch (error) {
    if (error instanceof JsoncError) {
      const errors = error.errors
      log.error(`Found ${$plur(errors.length, 'error', 'errors')} parsing ${$p(jsonFile)}`)
      for (const e of errors) {
        log.error(`  ${$wht(e.code)} ${$gry('at line')} ${$ylw(e.line)}${$gry(', column')} ${$ylw(e.column)}`)
      }

      throw new BuildFailure()
    } else throw error
  }
}
