/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
import { sep } from 'node:path'

import { assert, assertPromises } from './asserts'
import { getLogger } from './logging'
import { getAbsoluteParent, getCurrentWorkingDirectory, resolveAbsolutePath } from './paths'

import type { Files } from './files'
import type { Pipe } from './index'
import type { Logger } from './logging'
import type { AbsolutePath } from './paths'
import type { Result } from './types'

/* ========================================================================== *
 * PLUGS                                                                      *
 * ========================================================================== */

/** A convenience type indicating what can be returned by a {@link Plug}. */
export type PlugResult = Files | undefined | void

/**
 * The {@link Plug} interface describes _build plugin_.
 *
 * A {@link Plug} receives a {@link Files} instance in its input (for example
 * a list of _source `.ts` files_) and optionally produces a possibly different
 * list (for example the _compiled `.js` files_).
 */
export interface Plug<T extends PlugResult> {
  pipe(files: Files, context: Context): T | Promise<T>
}

/** A type identifying a {@link Plug} as a `function` */
export type PlugFunction<T extends PlugResult> = Plug<T>['pipe']

/* ========================================================================== *
 * PLUG CONTEXT                                                               *
 * ========================================================================== */

/**
 * The {@link Context} class defines the context in which a {@link Plug}
 * is invoked.
 */
export class Context {
  /** The directory of the file where the task was defined (convenience). */
  public readonly buildDir: AbsolutePath
  /** The {@link Logger} associated with this instance. */
  public readonly log: Logger

  constructor(
      /** The absolute file name where the task was defined. */
      public readonly buildFile: AbsolutePath,
      /** The _name_ of the task associated with this {@link Context}. */
      public readonly taskName: string,
  ) {
    this.buildDir = getAbsoluteParent(buildFile)
    this.log = getLogger(taskName)
  }

  /**
   * Resolve a (set of) path(s) in this {@link Context}.
   *
   * 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.
   */
  resolve(path: string, ...paths: string[]): AbsolutePath {
    // Paths starting with "@" are relative to the build file directory
    if (path && path.startsWith('@')) {
      // We can have paths like "@/../foo/bar" or "@../foo/bar"... both are ok
      const components = path.substring(1).split(sep).filter((s) => !!s)
      return resolveAbsolutePath(this.buildDir, ...components, ...paths)
    }

    // No path? Resolve to the CWD!
    if (! path) return getCurrentWorkingDirectory()

    // For all the rest, normal resolution!
    return resolveAbsolutePath(getCurrentWorkingDirectory(), path, ...paths)
  }
}

/* ========================================================================== *
 * PIPES                                                                      *
 * ========================================================================== */

/**
 * In pipe chains, we want to keep track of the _leaf_ promises (that
 * is, when a derived pipe is created calling `plug` we want to track only the
 * new, derived, promise).
 *
 * We key these _leaf_ promises by _context_ (with a WeakMap), and those will
 * be awaited at the end of the task.
 */
const contextPromises = new WeakMap<Context, ContextPromises>()

/**
 * An internal class recording _hot_ (failure will fail the task) and _cold_
 * (failure will be ignored) {@link Promise}s for a task's {@link Context}.
 */
export class ContextPromises {
  private readonly _cold = new Set<Promise<Result>>()
  private readonly _hot = new Set<Promise<Result>>()

  /* Private constructor */
  private constructor(readonly context: Context) {}

  /** Track a {@link Promise} _hot_ (failure will fail the task) */
  hot(promise: Promise<Result>): void {
    this._cold.delete(promise)
    this._hot.add(promise)
  }

  /** Track a {@link Promise} _cold_ (failure will be ignored) */
  cold(promise: Promise<Result>): void {
    this._hot.delete(promise)
    this._cold.add(promise)
  }

  /**
   * Await all tracked {@link Promise}s, triggering a build failure if any of
   * the _hot_ ones is rejected.
   */
  static async wait(context: Context): Promise<void> {
    const instance = contextPromises.get(context)
    if (! instance) return

    await Promise.allSettled([ ...instance._cold ])
    await assertPromises([ ...instance._hot ])
  }

  /** Get a {@link ContextPromises} instance for the given {@link Context} */
  static get(context: Context): ContextPromises {
    let promises = contextPromises.get(context)
    if (! promises) {
      promises = new ContextPromises(context)
      contextPromises.set(context, promises)
    }
    return promises
  }
}

/** The default implementation of the {@link Pipe} interface. */
export interface PipeImpl extends Pipe {
  // used simply for merging types
}

/** The default implementation of the {@link Pipe} interface. */
export class PipeImpl implements Pipe {
  readonly [Symbol.toStringTag] = 'Pipe'

  constructor(
      private readonly _context: Context,
      private readonly _promise: Promise<Result>,
  ) {
    // New "Pipe", remember the promise!
    ContextPromises.get(_context).hot(_promise)
  }

  /* ------------------------------------------------------------------------ *
   * Promise implementation                                                   *
   * ------------------------------------------------------------------------ *
   * From a _types_ point of view, the `Pipe` implements a `Promise<Files>`   *
   * (because only when plugging the correct `Plug` the correct value are     *
   * returned).                                                               *
   *                                                                          *
   * Whether to return (as a type) another `Pipe` or a `Promise<undefined>`   *
   * is determined by the type of the `plug` parameter below.                 *
   *                                                                          *
   * That said, in practice, a `Pipe` implements `Promise<Files | undefined>` *
   * because the result of the plug is _eventually_ computed asynchronously   *
   * while `plug` returns immediately.
   *                                                                          *
   * So, all those "as whatever" below are kind-of-legit...                   *
   * ------------------------------------------------------------------------ */

  then<R1 = Files, R2 = never>(
      onfulfilled?: ((value: Files) => R1 | PromiseLike<R1>) | null | undefined,
      onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null | undefined,
  ): Promise<R1 | R2> {
    // We are delegating the handling of this promise to the caller
    ContextPromises.get(this._context).cold(this._promise)
    return this._promise.then(onfulfilled as (value: Result) => R1 | PromiseLike<R1>, onrejected)
  }

  catch<R = never>(
      onrejected?: ((reason: any) => R | PromiseLike<R>) | null | undefined,
  ): Promise<Files | R> {
    // We are delegating the handling of this promise to the caller
    ContextPromises.get(this._context).cold(this._promise)
    return this._promise.catch(onrejected) as Promise<Files | R>
  }

  finally(onfinally?: (() => void) | null | undefined): Promise<Files> {
    // We are delegating the handling of this promise to the caller
    ContextPromises.get(this._context).cold(this._promise)
    return this._promise.finally(onfinally) as Promise<Files>
  }

  /* ------------------------------------------------------------------------ *
   * Pipe implementation                                                      *
   * ------------------------------------------------------------------------ */

  plug(plug: Plug<Files>): Pipe
  plug(plug: PlugFunction<Files>): Pipe
  plug(plug: Plug<void | undefined>): Promise<undefined>
  plug(plug: PlugFunction<void | undefined>): Promise<undefined>
  plug(arg: Plug<PlugResult> | PlugFunction<PlugResult>): Pipe | Promise<undefined> {
    const plug = typeof arg === 'function' ? { pipe: arg } : arg

    // We are creating a new "leaf" Pipe, we can forget our promise
    ContextPromises.get(this._context).cold(this._promise)

    // Create and return the new Pipe
    return new PipeImpl(this._context, this._promise.then(async (result) => {
      assert(result, 'Unable to extend pipe')
      const result2 = await plug.pipe(result, this._context)
      return result2 || undefined
    }))
  }
}


/* ========================================================================== *
 * PLUG INSTALLATION (NEW)                                                    *
 * ========================================================================== */

/** The names which can be installed as direct plugs. */
export type PlugName = string & Exclude<keyof Pipe, 'plug' | keyof Promise<any>>

/** The parameters of the plug extension with the given name */
export type PipeParameters<Name extends PlugName> = PipeOverloads<Name>['args']

/** Extract arguments and return types from function overloads. */
type PipeOverloads<Name extends PlugName> =
  Pipe[Name] extends {
    (...args: infer A0): infer R0
    (...args: infer A1): infer R1
    (...args: infer A2): infer R2
    (...args: infer A3): infer R3
    (...args: infer A4): infer R4
  } ?
    | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never)
    | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never)
    | (R2 extends (Pipe | Promise<undefined>) ? { args: A2, ret: R2 } : never)
    | (R3 extends (Pipe | Promise<undefined>) ? { args: A3, ret: R3 } : never)
    | (R4 extends (Pipe | Promise<undefined>) ? { args: A4, ret: R4 } : never)
  :
  Pipe[Name] extends {
    (...args: infer A0): infer R0
    (...args: infer A1): infer R1
    (...args: infer A2): infer R2
    (...args: infer A3): infer R3
  } ?
    | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never)
    | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never)
    | (R2 extends (Pipe | Promise<undefined>) ? { args: A2, ret: R2 } : never)
    | (R3 extends (Pipe | Promise<undefined>) ? { args: A3, ret: R3 } : never)
  :
  Pipe[Name] extends {
    (...args: infer A0): infer R0
    (...args: infer A1): infer R1
    (...args: infer A2): infer R2
  } ?
    | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never)
    | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never)
    | (R2 extends (Pipe | Promise<undefined>) ? { args: A2, ret: R2 } : never)
  :
  Pipe[Name] extends {
    (...args: infer A0): infer R0
    (...args: infer A1): infer R1
  } ?
    | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never)
    | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never)
  :
  Pipe[Name] extends {
    (...args: infer A0): infer R0
  } ?
    | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never)
  : never

/** The parameters of the plug extension with the given name */
type PipeResult<Name extends PlugName> = PipeOverloads<Name>['ret']

/**
 * A type defining the correct constructor for a {@link Plug}, inferring
 * argument types and instance type from the definitions in {@link Pipe}.
 */
type PlugConstructor<Name extends PlugName> =
  PipeResult<Name> extends Pipe ?
    new (...args: PipeParameters<Name>) => Plug<Files> :
  PipeResult<Name> extends Promise<undefined> ?
    new (...args: PipeParameters<Name>) => Plug<void | undefined> :
  PipeResult<Name> extends (Pipe | Promise<undefined>) ?
    new (...args: PipeParameters<Name>) => Plug<Files | void | undefined> :
  never

/**
 * Install a {@link Plug} into our {@link Pipe} prototype.
 *
 * This allows our shorthand syntax for well-defined plugs such as:
 *
 * ```
 * find('./src', '*.ts').write('./target')
 * // Nicer and easier than...
 * find('./src', '*.ts').plug(new Write('./target'))
 * ```
 *
 * Use this alongside interface merging like:
 *
 * ```
 * declare module '@plugjs/plug/pipe' {
 *   export interface Pipe {
 *     write(): Pipe
 *   }
 * }
 *
 * install('write', class Write implements Plug {
 *   constructorg(...args: PipeParams<'write'>) {
 *     // here `args` is automatically inferred by whatever was declared above
 *   }
 *
 *   // ... the plug implementation lives here
 * })
 * ```
 */
export function install<
  Name extends PlugName,
  Ctor extends PlugConstructor<Name>,
>(name: Name, ctor: Ctor): void {
  /* The function plugging the newly constructed plug in a pipe */
  function plug(this: PipeImpl, ...args: PipeParameters<Name>): Pipe | Promise<undefined> {
    // eslint-disable-next-line new-cap
    return this.plug(new ctor(...args) as any)
  }

  /* Setup name so that stack traces look better */
  Object.defineProperty(plug, 'name', { value: name })

  /* Inject the create function in the Pipe's prototype */
  void Object.defineProperty(PipeImpl.prototype, name, { value: plug })
}
