import { AssertionError } from 'node:assert';

import { Env, TimeUtil, Runtime, castTo, classConstruct } from '@travetto/runtime';
import { Registry } from '@travetto/registry';

import type { TestConfig, TestResult, TestRun } from '../model/test.ts';
import type { SuiteConfig, SuiteFailure, SuiteResult } from '../model/suite.ts';
import type { TestConsumerShape } from '../consumer/types.ts';
import { AssertCheck } from '../assert/check.ts';
import { AssertCapture } from '../assert/capture.ts';
import { ConsoleCapture } from './console.ts';
import { TestPhaseManager } from './phase.ts';
import { AssertUtil } from '../assert/util.ts';
import { Barrier } from './barrier.ts';
import { ExecutionError } from './error.ts';
import { SuiteRegistryIndex } from '../registry/registry-index.ts';
import { TestModelUtil } from '../model/util.ts';

const TEST_TIMEOUT = TimeUtil.fromValue(Env.TRV_TEST_TIMEOUT.value) ?? 5000;

/**
 * Support execution of the tests
 */
export class TestExecutor {

  #consumer: TestConsumerShape;

  constructor(consumer: TestConsumerShape) {
    this.#consumer = consumer;
  }

  /**
   * Handles communicating a suite-level error
   * @param failure
   * @param withSuite
   */
  #onSuiteFailure(failure: SuiteFailure, triggerSuite?: boolean): void {
    if (triggerSuite) {
      this.#consumer.onEvent({ type: 'suite', phase: 'before', suite: failure.suite });
    }

    this.#consumer.onEvent({ type: 'test', phase: 'before', test: failure.test });
    this.#consumer.onEvent({ type: 'assertion', phase: 'after', assertion: failure.assert });
    this.#consumer.onEvent({ type: 'test', phase: 'after', test: failure.testResult });

    if (triggerSuite) {
      this.#consumer.onEvent({
        type: 'suite', phase: 'after',
        suite: { ...castTo(failure.suite), failed: 1, passed: 0, total: 1, skipped: 0 }
      });
    }
  }

  /**
   * Raw execution, runs the method and then returns any thrown errors as the result.
   *
   * This method should never throw under any circumstances.
   */
  async #executeTestMethod(test: TestConfig): Promise<Error | undefined> {
    const suite = SuiteRegistryIndex.getConfig(test.class);

    // Ensure all the criteria below are satisfied before moving forward
    return Barrier.awaitOperation(test.timeout || TEST_TIMEOUT, async () => {
      const env = process.env;
      process.env = { ...env }; // Created an isolated environment
      try {
        await castTo<Record<string, Function>>(suite.instance)[test.methodName]();
      } finally {
        process.env = env; // Restore
      }
    });
  }

  /**
   * Determining if we should skip
   */
  async #shouldSkip(config: TestConfig | SuiteConfig, inst: unknown): Promise<boolean | undefined> {
    if (typeof config.skip === 'function' ? await config.skip(inst) : config.skip) {
      return true;
    }
  }

  #skipTest(test: TestConfig, result: SuiteResult): void {
    // Mark test start
    this.#consumer.onEvent({ type: 'test', phase: 'before', test });
    result.skipped++;
    this.#consumer.onEvent({ type: 'test', phase: 'after', test: { ...test, assertions: [], duration: 0, durationTotal: 0, output: [], status: 'skipped' } });
  }

  /**
   * An empty suite result based on a suite config
   */
  createSuiteResult(suite: SuiteConfig): SuiteResult {
    return {
      passed: 0,
      failed: 0,
      skipped: 0,
      unknown: 0,
      total: 0,
      status: 'unknown',
      lineStart: suite.lineStart,
      lineEnd: suite.lineEnd,
      import: suite.import,
      classId: suite.classId,
      sourceHash: suite.sourceHash,
      duration: 0,
      tests: {}
    };
  }

  /**
   * Execute the test, capture output, assertions and promises
   */
  async executeTest(test: TestConfig): Promise<TestResult> {

    // Mark test start
    this.#consumer.onEvent({ type: 'test', phase: 'before', test });

    const startTime = Date.now();

    const result: TestResult = {
      methodName: test.methodName,
      description: test.description,
      classId: test.classId,
      tags: test.tags,
      lineStart: test.lineStart,
      lineEnd: test.lineEnd,
      lineBodyStart: test.lineBodyStart,
      import: test.import,
      sourceImport: test.sourceImport,
      sourceHash: test.sourceHash,
      status: 'unknown',
      assertions: [],
      duration: 0,
      durationTotal: 0,
      output: [],
    };

    // Emit every assertion as it occurs
    const getAssertions = AssertCapture.collector(test, asrt =>
      this.#consumer.onEvent({
        type: 'assertion',
        phase: 'after',
        assertion: asrt
      })
    );

    const consoleCapture = new ConsoleCapture().start(); // Capture all output from transpiled code

    // Run method and get result
    let error = await this.#executeTestMethod(test);

    if (!error) {
      error = AssertCheck.checkError(test.shouldThrow, error); // Rewrite error
    } else {
      if (error instanceof AssertionError) {
        // Pass, do nothing
      } else if (error instanceof ExecutionError) { // Errors that are not expected
        AssertCheck.checkUnhandled(test, error);
      } else if (test.shouldThrow) {
        error = AssertCheck.checkError(test.shouldThrow, error); // Rewrite error
      } else if (error instanceof Error) {
        AssertCheck.checkUnhandled(test, error);
      }
    }

    Object.assign(result, {
      status: error ? 'failed' : 'passed',
      output: consoleCapture.end(),
      assertions: getAssertions(),
      duration: Date.now() - startTime,
      ...(error ? { error } : {})
    });

    // Mark completion
    this.#consumer.onEvent({ type: 'test', phase: 'after', test: result });

    return result;
  }

  /**
   * Execute an entire suite
   */
  async executeSuite(suite: SuiteConfig, tests: TestConfig[]): Promise<void> {

    suite.instance = classConstruct(suite.class);

    if (!tests.length || await this.#shouldSkip(suite, suite.instance)) {
      return;
    }

    const result: SuiteResult = this.createSuiteResult(suite);
    const validTestMethodNames = new Set(tests.map(t => t.methodName));
    const testConfigs = Object.fromEntries(
      Object.entries(suite.tests).filter(([key]) => validTestMethodNames.has(key))
    );

    const startTime = Date.now();

    // Mark suite start
    this.#consumer.onEvent({ phase: 'before', type: 'suite', suite: { ...suite, tests: testConfigs } });

    const manager = new TestPhaseManager(suite, result, event => this.#onSuiteFailure(event));

    const originalEnv = { ...process.env };

    try {
      // Handle the BeforeAll calls
      await manager.startPhase('all');

      const suiteEnv = { ...process.env };

      for (const test of tests ?? suite.tests) {
        if (await this.#shouldSkip(test, suite.instance)) {
          this.#skipTest(test, result);
          continue;
        }

        // Reset env before each test
        process.env = { ...suiteEnv };

        const testStart = Date.now();

        // Handle BeforeEach
        await manager.startPhase('each');

        // Run test
        const testResult = await this.executeTest(test);
        result[testResult.status]++;
        result.tests[testResult.methodName] = testResult;

        // Handle after each
        await manager.endPhase('each');
        testResult.durationTotal = Date.now() - testStart;
      }

      // Handle after all
      await manager.endPhase('all');
    } catch (error) {
      await manager.onError(error);
    }

    // Restore env
    process.env = { ...originalEnv };

    result.duration = Date.now() - startTime;
    result.total = result.passed + result.failed + result.skipped;
    result.status = TestModelUtil.countsToTestStatus(result);

    // Mark suite complete
    this.#consumer.onEvent({ phase: 'after', type: 'suite', suite: result });
  }

  /**
   * Handle executing a suite's test/tests based on command line inputs
   */
  async execute(run: TestRun): Promise<void> {
    try {
      await Runtime.importFrom(run.import);
    } catch (error) {
      if (!(error instanceof Error)) {
        throw error;
      }
      console.error(error);
      this.#onSuiteFailure(AssertUtil.gernerateImportFailure(run.import, error));
      return;
    }

    // Initialize registry (after loading the above)
    await Registry.finalizeForIndex(SuiteRegistryIndex);

    // Convert inbound arguments to specific tests to run
    const suites = SuiteRegistryIndex.getSuiteTests(run);
    if (!suites.length) {
      console.warn('Unable to find suites for ', run);
    }

    for (const { suite, tests } of suites) {
      await this.executeSuite(suite, tests);
    }
  }
}