import { Merged, mergeParams, mergeParamsChecked } from '../internal/params_utils.js';
import { comparePublicParamsPaths, Ordering } from '../internal/query/compare.js';
import { stringifyPublicParams } from '../internal/query/stringify_params.js';
import { DeepReadonly } from '../util/types.js';
import { assert, mapLazy, objectEquals } from '../util/util.js';

import { TestParams } from './fixture.js';

// ================================================================
// "Public" ParamsBuilder API / Documentation
// ================================================================

/**
 * Provides doc comments for the methods of CaseParamsBuilder and SubcaseParamsBuilder.
 * (Also enforces rough interface match between them.)
 */
export interface ParamsBuilder {
  /**
   * Expands each item in `this` into zero or more items.
   * Each item has its parameters expanded with those returned by the `expander`.
   *
   * **Note:** When only a single key is being added, use the simpler `expand` for readability.
   *
   * ```text
   *               this = [     a       ,      b     ,       c       ]
   * this.map(expander) = [   f(a)           f(b)          f(c)      ]
   *                    = [[a1, a2, a3] ,    [ b1 ]  ,       []      ]
   *  merge and flatten = [ merge(a, a1), merge(a, a2), merge(a, a3), merge(b, b1) ]
   * ```
   */
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  expandWithParams(expander: (_: any) => any): any;

  /**
   * Expands each item in `this` into zero or more items. Each item has its parameters expanded
   * with one new key, `key`, and the values returned by `expander`.
   */
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  expand(key: string, expander: (_: any) => any): any;

  /**
   * Expands each item in `this` to multiple items, one for each item in `newParams`.
   *
   * In other words, takes the cartesian product of [ the items in `this` ] and `newParams`.
   *
   * **Note:** When only a single key is being added, use the simpler `combine` for readability.
   *
   * ```text
   *                     this = [ {a:1}, {b:2} ]
   *                newParams = [ {x:1}, {y:2} ]
   * this.combineP(newParams) = [ {a:1,x:1}, {a:1,y:2}, {b:2,x:1}, {b:2,y:2} ]
   * ```
   */
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  combineWithParams(newParams: Iterable<any>): any;

  /**
   * Expands each item in `this` to multiple items with `{ [name]: value }` for each value.
   *
   * In other words, takes the cartesian product of [ the items in `this` ]
   * and `[ {[name]: value} for each value in values ]`
   */
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  combine(key: string, newParams: Iterable<any>): any;

  /**
   * Filters `this` to only items for which `pred` returns true.
   */
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  filter(pred: (_: any) => boolean): any;

  /**
   * Filters `this` to only items for which `pred` returns false.
   */
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  unless(pred: (_: any) => boolean): any;
}

/**
 * Determines the resulting parameter object type which would be generated by an object of
 * the given ParamsBuilder type.
 */
export type ParamTypeOf<
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  T extends ParamsBuilder,
> = T extends SubcaseParamsBuilder<infer CaseP, infer SubcaseP>
  ? Merged<CaseP, SubcaseP>
  : T extends CaseParamsBuilder<infer CaseP>
  ? CaseP
  : never;

// ================================================================
// Implementation
// ================================================================

/**
 * Iterable over pairs of either:
 * - `[case params, Iterable<subcase params>]` if there are subcases.
 * - `[case params, undefined]` if not.
 */
export type CaseSubcaseIterable<CaseP, SubcaseP> = Iterable<
  readonly [DeepReadonly<CaseP>, Iterable<DeepReadonly<SubcaseP>> | undefined]
>;

/**
 * Base class for `CaseParamsBuilder` and `SubcaseParamsBuilder`.
 */
export abstract class ParamsBuilderBase<CaseP extends {}, SubcaseP extends {}> {
  protected readonly cases: (caseFilter: TestParams | null) => Generator<CaseP>;

  constructor(cases: (caseFilter: TestParams | null) => Generator<CaseP>) {
    this.cases = cases;
  }

  /**
   * Hidden from test files. Use `builderIterateCasesWithSubcases` to access this.
   */
  protected abstract iterateCasesWithSubcases(
    caseFilter: TestParams | null
  ): CaseSubcaseIterable<CaseP, SubcaseP>;
}

/**
 * Calls the (normally hidden) `iterateCasesWithSubcases()` method.
 */
export function builderIterateCasesWithSubcases(
  builder: ParamsBuilderBase<{}, {}>,
  caseFilter: TestParams | null
) {
  interface IterableParamsBuilder {
    iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<{}, {}>;
  }

  return (builder as unknown as IterableParamsBuilder).iterateCasesWithSubcases(caseFilter);
}

/**
 * Builder for combinatorial test **case** parameters.
 *
 * CaseParamsBuilder is immutable. Each method call returns a new, immutable object,
 * modifying the list of cases according to the method called.
 *
 * This means, for example, that the `unit` passed into `TestBuilder.params()` can be reused.
 */
export class CaseParamsBuilder<CaseP extends {}>
  extends ParamsBuilderBase<CaseP, {}>
  implements Iterable<DeepReadonly<CaseP>>, ParamsBuilder
{
  *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, {}> {
    for (const caseP of this.cases(caseFilter)) {
      if (caseFilter) {
        // this.cases() only filters out cases which conflict with caseFilter. Now that we have
        // the final caseP, filter out cases which are missing keys that caseFilter requires.
        const ordering = comparePublicParamsPaths(caseP, caseFilter);
        if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) {
          continue;
        }
      }

      yield [caseP as DeepReadonly<typeof caseP>, undefined];
    }
  }

  [Symbol.iterator](): Iterator<DeepReadonly<CaseP>> {
    return this.cases(null) as Iterator<DeepReadonly<CaseP>>;
  }

  /** @inheritDoc */
  expandWithParams<NewP extends {}>(
    expander: (_: CaseP) => Iterable<NewP>
  ): CaseParamsBuilder<Merged<CaseP, NewP>> {
    const baseGenerator = this.cases;
    return new CaseParamsBuilder(function* (caseFilter) {
      for (const a of baseGenerator(caseFilter)) {
        for (const b of expander(a)) {
          if (caseFilter) {
            // If the expander generated any key-value pair that conflicts with caseFilter, skip.
            const kvPairs = Object.entries(b);
            if (kvPairs.some(([k, v]) => k in caseFilter && !objectEquals(caseFilter[k], v))) {
              continue;
            }
          }

          yield mergeParamsChecked(a, b);
        }
      }
    });
  }

  /** @inheritDoc */
  expand<NewPKey extends string, NewPValue>(
    key: NewPKey,
    expander: (_: CaseP) => Iterable<NewPValue>
  ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> {
    const baseGenerator = this.cases;
    return new CaseParamsBuilder(function* (caseFilter) {
      for (const a of baseGenerator(caseFilter)) {
        assert(!(key in a), `New key '${key}' already exists in ${JSON.stringify(a)}`);

        for (const v of expander(a)) {
          // If the expander generated a value for this key that conflicts with caseFilter, skip.
          if (caseFilter && key in caseFilter) {
            if (!objectEquals(caseFilter[key], v)) {
              continue;
            }
          }
          yield { ...a, [key]: v } as Merged<CaseP, { [name in NewPKey]: NewPValue }>;
        }
      }
    });
  }

  /** @inheritDoc */
  combineWithParams<NewP extends {}>(
    newParams: Iterable<NewP>
  ): CaseParamsBuilder<Merged<CaseP, NewP>> {
    assertNotGenerator(newParams);
    const seenValues = new Set<string>();
    for (const params of newParams) {
      const paramsStr = stringifyPublicParams(params);
      assert(!seenValues.has(paramsStr), `Duplicate entry in combine[WithParams]: ${paramsStr}`);
      seenValues.add(paramsStr);
    }

    return this.expandWithParams(() => newParams);
  }

  /** @inheritDoc */
  combine<NewPKey extends string, NewPValue>(
    key: NewPKey,
    values: Iterable<NewPValue>
  ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> {
    assertNotGenerator(values);
    const mapped = mapLazy(values, v => ({ [key]: v }) as { [name in NewPKey]: NewPValue });
    return this.combineWithParams(mapped);
  }

  /** @inheritDoc */
  filter(pred: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> {
    const baseGenerator = this.cases;
    return new CaseParamsBuilder(function* (caseFilter) {
      for (const a of baseGenerator(caseFilter)) {
        if (pred(a)) yield a;
      }
    });
  }

  /** @inheritDoc */
  unless(pred: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> {
    return this.filter(x => !pred(x));
  }

  /**
   * "Finalize" the list of cases and begin defining subcases.
   * Returns a new SubcaseParamsBuilder. Methods called on SubcaseParamsBuilder
   * generate new subcases instead of new cases.
   */
  beginSubcases(): SubcaseParamsBuilder<CaseP, {}> {
    return new SubcaseParamsBuilder(this.cases, function* () {
      yield {};
    });
  }
}

/**
 * The unit CaseParamsBuilder, representing a single case with no params: `[ {} ]`.
 *
 * `punit` is passed to every `.params()`/`.paramsSubcasesOnly()` call, so `kUnitCaseParamsBuilder`
 * is only explicitly needed if constructing a ParamsBuilder outside of a test builder.
 */
export const kUnitCaseParamsBuilder = new CaseParamsBuilder(function* () {
  yield {};
});

/**
 * Builder for combinatorial test _subcase_ parameters.
 *
 * SubcaseParamsBuilder is immutable. Each method call returns a new, immutable object,
 * modifying the list of subcases according to the method called.
 */
export class SubcaseParamsBuilder<CaseP extends {}, SubcaseP extends {}>
  extends ParamsBuilderBase<CaseP, SubcaseP>
  implements ParamsBuilder
{
  protected readonly subcases: (_: CaseP) => Generator<SubcaseP>;

  constructor(
    cases: (caseFilter: TestParams | null) => Generator<CaseP>,
    generator: (_: CaseP) => Generator<SubcaseP>
  ) {
    super(cases);
    this.subcases = generator;
  }

  *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, SubcaseP> {
    for (const caseP of this.cases(caseFilter)) {
      if (caseFilter) {
        // this.cases() only filters out cases which conflict with caseFilter. Now that we have
        // the final caseP, filter out cases which are missing keys that caseFilter requires.
        const ordering = comparePublicParamsPaths(caseP, caseFilter);
        if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) {
          continue;
        }
      }

      const subcases = Array.from(this.subcases(caseP));
      if (subcases.length) {
        yield [
          caseP as DeepReadonly<typeof caseP>,
          subcases as DeepReadonly<(typeof subcases)[number]>[],
        ];
      }
    }
  }

  /** @inheritDoc */
  expandWithParams<NewP extends {}>(
    expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewP>
  ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> {
    const baseGenerator = this.subcases;
    return new SubcaseParamsBuilder(this.cases, function* (base) {
      for (const a of baseGenerator(base)) {
        for (const b of expander(mergeParams(base, a))) {
          yield mergeParamsChecked(a, b);
        }
      }
    });
  }

  /** @inheritDoc */
  expand<NewPKey extends string, NewPValue>(
    key: NewPKey,
    expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewPValue>
  ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> {
    const baseGenerator = this.subcases;
    return new SubcaseParamsBuilder(this.cases, function* (base) {
      for (const a of baseGenerator(base)) {
        const before = mergeParams(base, a);
        assert(!(key in before), () => `Key '${key}' already exists in ${JSON.stringify(before)}`);

        for (const v of expander(before)) {
          yield { ...a, [key]: v } as Merged<SubcaseP, { [k in NewPKey]: NewPValue }>;
        }
      }
    });
  }

  /** @inheritDoc */
  combineWithParams<NewP extends {}>(
    newParams: Iterable<NewP>
  ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> {
    assertNotGenerator(newParams);
    return this.expandWithParams(() => newParams);
  }

  /** @inheritDoc */
  combine<NewPKey extends string, NewPValue>(
    key: NewPKey,
    values: Iterable<NewPValue>
  ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> {
    assertNotGenerator(values);
    return this.expand(key, () => values);
  }

  /** @inheritDoc */
  filter(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> {
    const baseGenerator = this.subcases;
    return new SubcaseParamsBuilder(this.cases, function* (base) {
      for (const a of baseGenerator(base)) {
        if (pred(mergeParams(base, a))) yield a;
      }
    });
  }

  /** @inheritDoc */
  unless(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> {
    return this.filter(x => !pred(x));
  }
}

/** Assert an object is not a Generator (a thing returned from a generator function). */
function assertNotGenerator(x: object) {
  if ('constructor' in x) {
    assert(
      x.constructor !== (function* () {})().constructor,
      'Argument must not be a generator, as generators are not reusable'
    );
  }
}
