/**
 * Executed hooks of test run.
 *
 * In Playwright:
 * worker-level hooks (BeforeAll / AfterAll) are still part of
 * particular test results. E.g. BeforeAll is reported as a step of the first test in a worker.
 *
 * In Cucumber:
 * Worker-level hooks are not considered to be a part of any test case.
 * They will be reported as separate messages TestRunHookStarted / TestRunHookFinished.
 * See: https://github.com/cucumber/messages/pull/102
 *
 * In playwright-bdd:
 * We do map worker-level hooks to BEFORE_TEST_RUN / AFTER_TEST_RUN hook types,
 * but we don't emit TestRunHookStarted / TestRunHookFinished messages.
 * Instead, these hooks are attached to per-scenario testCase steps in the report.
 * This could be changed in the future, when cucumber-js will add it.
 */

import * as pw from '@playwright/test/reporter';
import { Hook, HooksGroup } from './Hook';
import {
  getHooksRootPwStep,
  walkSteps,
  findDeepestStepWith,
  isUnknownDuration,
} from './pwStepUtils';
import { TestCaseRun } from './TestCaseRun';
import { TestStepRun, TestStepRunEnvelope } from './TestStepRun';

const PW_STEP_CATEGORIES_FOR_HOOKS = ['hook', 'fixture', 'test.step'];

type ExecutedHookInfo = {
  hook: Hook;
  pwStep: pw.TestStep;
};

export function isHookCandidate(pwStep: pw.TestStep) {
  return PW_STEP_CATEGORIES_FOR_HOOKS.includes(pwStep.category);
}

export class TestCaseRunHooks {
  // rootPwStep is empty for skipped test, b/c there are no steps.
  // todo: refactor to avoid "!"
  private rootPwStep!: pw.TestStep;
  private candidates: pw.TestStep[] = [];
  private hookPwSteps = new Set<pw.TestStep>();

  executedHooks = new Map</* internalId */ string, ExecutedHookInfo>();

  static getRootPwStep(result: TestCaseRun, hookType: HooksGroup) {
    return getHooksRootPwStep(result.result, hookType);
  }

  constructor(
    private testCaseRun: TestCaseRun,
    private hookType: HooksGroup,
    private bgRoots: Set<pw.TestStep>,
  ) {}

  private get isAfterHooksType() {
    return this.hookType === 'after';
  }

  fill() {
    this.setRootPwStep();
    // todo: refactor to this early return
    if (!this.rootPwStep) return this;
    this.setCandidates();
    this.addStepsWithName();
    this.addStepWithTimeout();
    this.addStepWithError();
    this.addStepsWithAttachment();
    if (this.isAfterHooksType) {
      this.addUnprocessedErrors();
      this.addUnprocessedAttachments();
    }
    this.setExecutedHooks();
    return this;
  }

  buildMessages() {
    const messages: TestStepRunEnvelope[] = [];
    this.testCaseRun
      .getTestCase()
      .getHooks(this.hookType)
      .forEach((hookInfo) => {
        const executedHook = this.executedHooks.get(hookInfo.hook.internalId);
        // If no pwStep is found for this hook, it means the hook was not executed in this attempt.
        // This happens for BEFORE_TEST_RUN (BeforeAll) hooks on retries: PW credits BeforeAll to the
        // first scenario in each worker. When a later scenario is retried, its attempt 1 runs BeforeAll
        // in the new worker (and has a real pwStep), which causes TestCase.addHooks() to add BeforeAll
        // to the shared TestCase object. buildMessages() for attempt 0 then finds BeforeAll in the
        // TestCase but has no matching pwStep. Passing undefined pwStep lets TestStepRun emit SKIPPED,
        // which correctly reflects that the hook ran for a different scenario in the same worker.
        const pwStep = executedHook?.pwStep;
        const testStepRun = new TestStepRun(this.testCaseRun, hookInfo.testStep, pwStep);
        messages.push(...testStepRun.buildMessages());
      });

    return messages;
  }

  private setRootPwStep() {
    // todo: refactor to better handle empty root step (e.g. skipped test)
    this.rootPwStep = getHooksRootPwStep(this.testCaseRun.result, this.hookType)!;
  }

  private setCandidates() {
    // collect steps, not entering into bg roots
    this.candidates = walkSteps(this.rootPwStep, (pwStep) => !this.bgRoots.has(pwStep)).filter(
      (pwStep) => isHookCandidate(pwStep),
    );
  }

  private addStepsWithName() {
    this.candidates
      .filter((pwStep) => pwStep.category === 'test.step' && pwStep.title)
      .forEach((pwStep) => this.hookPwSteps.add(pwStep));
  }

  private addStepWithTimeout() {
    if (!this.testCaseRun.isTimeouted()) return;
    if (this.testCaseRun.timeoutedStep) return;

    // Search timeouted step by duration = -1.
    // This is not 100% method, sometimes timeouted steps have real duration value.
    // But allows to better place timeout error message in report.
    const timeoutedStep = findDeepestStepWith(this.candidates, (pwStep) => {
      return isUnknownDuration(pwStep);
    });
    if (!timeoutedStep) return;

    this.hookPwSteps.add(timeoutedStep);
    this.testCaseRun.registerTimeoutedStep(timeoutedStep);
  }

  private addStepWithError() {
    // In case of several errors in after hooks (as they all run),
    // parent pwStep and test result contain only the last error,
    // but each hook step itself contains own error.
    // Here we find only first step with error.
    // Todo: find and show all errors for after hooks.
    const stepWithError = findDeepestStepWith(this.candidates, (pwStep) => Boolean(pwStep.error));
    if (!stepWithError) return;

    const error = stepWithError.error!;

    // If step is already added to errorSteps, don't register it as hookStep.
    // This is mainly for timeout steps in hooks or bg:
    // They have duration -1 and no 'error' field, but their parent has 'error' field.
    // Here we find this parent again and avoid reporting the error twice.
    if (!this.testCaseRun.hasRegisteredError(error)) {
      this.registerErrorStep(stepWithError, error);
    }
  }

  /**
   * If there are unprocessed errors, attach them to After Hooks root step.
   */
  private addUnprocessedErrors() {
    const unprocessedErrors = this.testCaseRun.getUnprocessedErrors();
    if (unprocessedErrors.length === 0) return;

    const error = buildFallbackError(unprocessedErrors);

    // if there is timeouted step without attached error, attach all unprocessed errors to it,
    // otherwise attach unprocessed error to root 'After Hooks' step.
    const { timeoutedStep } = this.testCaseRun;
    if (timeoutedStep && !this.testCaseRun.getStepError(timeoutedStep)) {
      this.testCaseRun.registerErrorStep(timeoutedStep, error);
    } else {
      this.registerErrorStep(this.rootPwStep, error);
    }
  }

  private addStepsWithAttachment() {
    const { attachmentMapper } = this.testCaseRun;
    this.candidates.forEach((pwStep) => {
      const attachments = attachmentMapper.populateStepAttachments(pwStep, { fromHook: true });
      if (attachments.length > 0) this.hookPwSteps.add(pwStep);
    });
  }

  private addUnprocessedAttachments() {
    const { attachmentMapper } = this.testCaseRun;
    if (attachmentMapper.hasUnprocessedAttachments()) {
      attachmentMapper.mapUnprocessedAttachments(this.rootPwStep);
      this.hookPwSteps.add(this.rootPwStep);
    }
  }

  private setExecutedHooks() {
    this.hookPwSteps.forEach((pwStep) => {
      const internalId = Hook.getInternalId(pwStep);
      const hook = this.getOrRegisterHook(pwStep);
      this.executedHooks.set(internalId, { hook, pwStep });
    });
  }

  private getOrRegisterHook(pwStep: pw.TestStep) {
    const internalId = Hook.getInternalId(pwStep);
    return this.testCaseRun.hooks.getOrCreate(
      internalId,
      () => new Hook(internalId, this.hookType, pwStep),
    );
  }

  private registerErrorStep(pwStep: pw.TestStep, error: pw.TestError) {
    this.hookPwSteps.add(pwStep);
    this.testCaseRun.registerErrorStep(pwStep, error);
  }
}

function buildFallbackError(unprocessedErrors: pw.TestError[]) {
  const firstError = unprocessedErrors[0];

  return firstError && unprocessedErrors.length === 1
    ? firstError
    : buildConcatenatedError(unprocessedErrors);
}

function buildConcatenatedError(errors: pw.TestError[]) {
  const message = errors
    .map((e) => e.message)
    .filter(Boolean)
    .join('\n\n');
  return { message };
}
