import path from "path"
import * as _ from "lodash"

import SpecOptions from "./options"
import { toHrx } from "./hrx"

export type SpecIteratee = (subdir: SpecDirectory) => Promise<void>

/**
 * Represents a real or virtual directory that contains sass-spec test cases.
 *
 * Contains methods for accessing the direct files and subdirectories of the directory.
 */
export default abstract class SpecDirectory {
  protected readonly root: SpecDirectory
  private readonly parentOpts?: SpecOptions
  private readonly _subdirs: Record<string, SpecDirectory> = {}

  /** The full path of this directory */
  abstract path: string

  constructor(root?: SpecDirectory, parentOpts?: SpecOptions) {
    this.root = root ?? this
    this.parentOpts = parentOpts
  }

  /** The path of this directory relative to the top level that was created */
  relPath(): string {
    // make sure to include the root dir as part of the name
    // (e.g. if the root path is `spec`, everything should be listed as `spec/thing`)
    const rootDir = path.dirname(this.root.path)
    return path.relative(rootDir, this.path)
  }

  // File manipulation

  /** Get the list of direct filenames in this directory */
  abstract listFiles(): Promise<string[]>
  /** Returns whether the given file exists in this directory */
  abstract hasFile(filename: string): boolean
  /** Returns the file contents of the given filename */
  abstract readFile(filename: string): Promise<string>
  /** Update the contents of the given file in the directory */
  abstract writeFile(filename: string, contents: string): Promise<void>
  /** Remove the file from this directory */
  abstract removeFile(filename: string): Promise<void>

  // Subdirectories

  /** Get the subdirectory at the provided path relative to this directory */
  async atPath(subpath: string): Promise<SpecDirectory> {
    if (!subpath) return this
    const i = subpath.indexOf(path.sep)
    if (i === -1) {
      return await this.subdir(subpath)
    }
    const child = await this.subdir(subpath.slice(0, i))
    return await child.atPath(subpath.slice(i + 1))
  }

  // helper to get the subitem with the given name
  protected abstract getSubdir(name: string): Promise<SpecDirectory>

  /**
   * Return the subitem of this directory corresponding to the given name
   */
  async subdir(name: string): Promise<SpecDirectory> {
    // Cache the subitem so we always return the same one
    if (!this._subdirs[name]) {
      this._subdirs[name] = await this.getSubdir(name)
    }
    return this._subdirs[name]
  }

  // Get the ordered list of subdir names
  protected abstract listSubdirs(): Promise<string[]>

  /** Return the list of subdirectories */
  async subdirs(): Promise<SpecDirectory[]> {
    const list = await this.listSubdirs()
    return Promise.all(list.map((item) => this.subdir(item)))
  }

  // Spec Options

  // Get the options from a physical options.yml file
  async directOptions(): Promise<SpecOptions> {
    const contents = this.hasFile("options.yml")
      ? await this.readFile("options.yml")
      : ""
    // TODO validate run options
    return SpecOptions.fromYaml(contents)
  }

  /** Get the spec options of this directory, including those inherited from its parent */
  async options(): Promise<SpecOptions> {
    const opts = await this.directOptions()
    return this.parentOpts?.merge(opts) ?? opts
  }

  // Test case info

  /** Return whether this directory corresponds to a test case */
  isTestDir(): boolean {
    return this.hasFile("input.scss") || this.hasFile("input.sass")
  }

  /** Return the contents of this directory as an HRX archive */
  async asArchive(): Promise<string> {
    return await toHrx(this)
  }

  // Iteration

  async setup(): Promise<void> {}
  async cleanup(): Promise<void> {}

  /**
   * Iterate through the subpaths of this directory, running the iteratee
   * on all test case directories.
   *
   * @param iteratee the function to call for each matching subdirectory
   * @param only if this is passed, only paths that match these will be run
   * @throws {Error} if `only` contains any paths that aren't in this directory
   */
  async forEachTest(iteratee: SpecIteratee, only?: string[]): Promise<void> {
    const relPath = this.relPath()
    if (only === undefined || only.includes(relPath)) {
      if (this.isTestDir()) {
        // If this is a test directory, run the test
        await iteratee(this)
      } else {
        // Otherwise, iterate on *all* the subdirectories
        for (const subdir of await this.subdirs()) {
          await subdir.forEachTest(iteratee)
        }
      }
      return
    }

    // A map from the basename of each subdirectory to that subdirectory
    const subdirsByBasename = _.fromPairs(
      (await this.subdirs()).map((subdir) => [
        path.basename(subdir.path),
        subdir,
      ])
    )

    // A map from the first component of each path in `only` (for example, `foo`
    // in `foo/bar/baz`) to the full paths under that component.
    const onlyByFirstComponent = _.groupBy(
      only,
      (p) => path.normalize(path.relative(relPath, p)).split(path.sep)[0]
    )

    for (const [component, paths] of _.toPairs(onlyByFirstComponent)) {
      const subdir = subdirsByBasename[component]
      if (subdir) {
        await subdir.forEachTest(iteratee, paths)
      } else {
        throw new Error(`Path ${paths[0]} doesn't exist`)
      }
    }
  }
}
