/* eslint-disable no-console */
import { styleText } from 'util';
import { type WriteStream } from 'fs';
import * as React from 'react';
import {
  render, Text, useApp, useInput, useStdout,
} from 'ink';
import { TaskList, Task } from 'ink-task-list';
import { BarChart } from '@pppp606/ink-chart';
import { link, type Test } from './base.mts';

const { createElement: h } = React;
export const isCI = process.env.CI || process.env.CONTINUOUS_INTEGRATION;
export const supportColor = !isCI && styleText('red', 'test') !== 'test';

function Fragment(...children: (React.JSX.Element | null)[]) {
  return h(React.Fragment, null, ...children);
}

// from https://github.com/sindresorhus/cli-spinners
const spinner = {
  interval: 80,
  frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
};

const quitMessage = [styleText('gray', 'Press '), styleText('yellow', 'q'), styleText('gray', ' to exit.')].join('');
function TerminalUI({ runner }: { runner: TerminalUIReporter }) {
  useFlushStdout(runner);
  const exiting = useExit(runner);

  const tasks = useWorkers(runner);
  const runtime = JSON.parse(useRuntimeUpdate(runner)) as number[];
  const needPad = runtime.some((time) => time >= runner.slowThreshold);
  const runtimePadLength = needPad ? String(Math.max(...runtime)).length : 0;
  const previous = React.useRef<number[]>([]);

  return Fragment(
    h(ProgressBar, { runner, running: !exiting, exiting }),
    h(
      TaskList,
      null,
      ...(exiting ? [] : tasks).map((test, index) => {
        previous.current.length = tasks.length;
        previous.current[index] = test?.id ?? previous.current[index];

        let label: string;
        let padding = '';
        let status = '';
        let state: 'loading' | 'success' = 'loading';
        if (test) {
          label = test.file;
          status = test.currentTestFlag;
          if (runtime[index] >= runner.slowThreshold) {
            padding = styleText('red', `[${String(runtime[index]).padStart(runtimePadLength)}s] `);
          }
        } else {
          let test = runner.tests.get(previous.current[index]);
          if (test && (Date.now() - test.endTime! > 200)) {
            test = undefined;
          }
          label = styleText('dim', test?.file ?? 'Idle');
          status = test?.currentTestFlag ?? '';
          state = test ? 'loading' : 'success';
          if (!test && needPad) {
            padding = ' '.repeat(runtimePadLength + 3);
          }
        }
        if (!padding && needPad) {
          padding = ' '.repeat(runtimePadLength + 4);
        }
        label = `${padding}${label}`;

        return h(Task, {
          key: index, label, state, spinner, status,
        });
      }),
    ),
  );
}

function ProgressBar({ runner, running, exiting }: { runner: TestReporter, running: boolean, exiting: boolean }) {
  const stats = useStats(runner);
  if (!stats) {
    return null;
  }
  const {
    failed, passed, pending, skipped, total, ready,
  } = stats;
  if (!ready && running) {
    return h(
      Text,
      null,
      `Discovering tests... ${total} found so far. ${passed} passed, ${skipped} skipped, ${failed} failed, ${pending} pending. `,
      exiting ? '' : quitMessage,
    );
  }
  return Fragment(
    h(Text, null, `${total} tests in total. `, exiting ? '' : quitMessage),
    h(
      BarChart,
      {
        data: [
          { label: `${passed} passed`, value: passed, color: 'green' },
          { label: `${skipped} skipped`, value: skipped, color: 'yellow' },
          { label: `${failed} failed`, value: failed, color: 'red' },
          running || pending ? { label: `${pending} pending`, value: pending, color: 'cyan' } : null!,
        ].filter(Boolean),
        width: 'full',
        max: total,
      },
    ),
  );
}

function useStats(runner: TestReporter) {
  return React.useSyncExternalStore(
    (onUpdate) => {
      runner.addEventListener('stats', onUpdate);
      return () => runner.removeEventListener('stats', onUpdate);
    },
    () => runner.getStats(),
  );
}

function useWorkers(runner: TestReporter) {
  return React.useSyncExternalStore(
    (onUpdate) => {
      runner.addEventListener('update', onUpdate);
      return () => runner.removeEventListener('update', onUpdate);
    },
    () => runner.workers,
  );
}

function useRuntimeUpdate(runner: TestReporter) {
  return React.useSyncExternalStore(
    (onUpdate) => {
      runner.addEventListener('update', onUpdate);
      return () => runner.removeEventListener('update', onUpdate);
    },
    () => JSON.stringify(
      runner.workers.map((test) => test?.getRuntimeSeconds()),
    ),
  );
}

function useExit(runner: TestReporter) {
  const exitRequested = React.useSyncExternalStore(
    (onExit) => {
      runner.addEventListener('exit', onExit);
      return () => runner.removeEventListener('exit', onExit);
    },
    () => runner.exited,
  );
  const { exit } = useApp();
  const [previousExitState, setExitState] = React.useState<boolean>(false);
  if (exitRequested && !previousExitState) {
    setExitState(true);
    setTimeout(exit, 10);
  }
  useInput((input, key) => {
    if (input === 'q' || (key.ctrl && input === 'c')) {
      setExitState(true);
      runner.exit();
    }
  });
  return previousExitState;
}

function useFlushStdout(runner: TerminalUIReporter) {
  const stdout = useStdout();
  React.useEffect(() => {
    function flush() {
      stdout.write(runner.pending_stdout.join(''));
      runner.pending_stdout.length = 0;
    }
    runner.addEventListener('flush', flush);
    return () => runner.removeEventListener('flush', flush);
  });
}

export type SkipReason = 'feature-disabled' | 'skip-list' | 'slow-list';

export abstract class TestReporter extends EventTarget {
  tests: Map<number, Test> = new Map();

  workers: (Test | undefined)[] = [];

  protected skipped = 0;

  protected passed = 0;

  protected failed = 0;

  protected ready = false;

  setSlowTestReporting(threshold: number, stream: WriteStream) {
    this.slowThreshold = threshold;
    this.slowStream = stream;
  }

  slowThreshold = 2;

  protected slowStream: WriteStream | null = null;

  protected stats: { total: number; pending: number; passed: number; failed: number; skipped: number; ready: boolean } = this.getStats();

  protected statsStale = false;

  getStats() {
    if (!this.statsStale) {
      return this.stats;
    }
    this.statsStale = false;
    this.stats = {
      total: this.tests.size,
      pending: this.tests.size - this.passed - this.failed - this.skipped,
      passed: this.passed,
      failed: this.failed,
      skipped: this.skipped,
      ready: this.ready,
    };
    return this.stats;
  }

  exited = false;

  abstract stdout(...message: string[]): void

  abstract start(): void

  protected slowTimer: NodeJS.Timeout | null = null;

  protected startSlowTimer() {
    const seenSlow = new Set<string>();
    this.slowTimer = setInterval(() => {
      let hasSlow = false;
      for (const test of this.tests.values()) {
        if (test.getRuntimeSeconds() >= this.slowThreshold) {
          if (this.slowStream && !seenSlow.has(test.file)) {
            this.slowStream?.write(`${test.file}\n`);
            seenSlow.add(test.file);
          }
          hasSlow = true;
        }
      }
      if (hasSlow) {
        this.dispatchEvent(new Event('update'));
      }
    }, 400);
  }

  allTestsDiscovered() {
    this.ready = true;
    this.statsStale = true;
    this.dispatchEvent(new Event('stats'));
  }

  onExit = Promise.withResolvers<void>();

  exit() {
    this.exited = true;
    if (this.slowTimer) {
      clearInterval(this.slowTimer);
      this.slowTimer = null;
    }
    this.dispatchEvent(new Event('exit'));
  }

  addTest(test: Test) {
    this.tests.set(test.id, test);
    this.statsStale = true;
  }

  updateWorker(workerId: number, taskId: number | null) {
    if (this.workers.length <= workerId) {
      const next = [...this.workers];
      next.length = workerId + 1;
      this.workers = next;
    }
    if (taskId === null) {
      this.workers = this.workers.with(workerId, undefined);
    } else {
      const test = this.tests.get(taskId)!;
      test.status = 'running';
      test.startTime = Date.now();
      this.workers = this.workers.with(workerId, test!);
    }
    this.statsStale = true;
    this.dispatchEvent(new Event('update'));
  }

  skipTest(testId: number, reason: SkipReason, feature?: string) {
    const test = this.tests.get(testId)!;
    test.status = 'skipped';
    test.skipReason = reason;
    test.skipFeature = feature ?? null;
    test.content = '';
    this.skipped += 1;
    this.statsStale = true;
    test.endTime = Date.now();
    this.dispatchEvent(new Event('stats'));
  }

  testFailed(testId: number) {
    const test = this.tests.get(testId)!;
    test.status = 'failed';
    test.endTime = Date.now();
    test.content = '';
    this.failed += 1;
    this.statsStale = true;
    this.dispatchEvent(new Event('stats'));
  }

  assertFailedTestFails(testId: number) {
    this.testPassed(testId);
  }

  testPassed(testId: number) {
    const test = this.tests.get(testId)!;
    test.status = 'passed';
    test.endTime = Date.now();
    test.content = '';
    this.passed += 1;
    this.statsStale = true;
    this.dispatchEvent(new Event('stats'));
  }
}

class BasicReporter extends TestReporter {
  stdout(...message: string[]): void {
    process.stdout.write(message.join(''));
  }

  start(): void {
    this.timer = setInterval(() => {
      const {
        failed, passed, pending, ready, skipped, total,
      } = this.getStats();
      if (ready) {
        console.log(`Total ${total}, ${passed} passed, ${skipped} skipped, ${failed} failed, ${pending} pending`);
      } else {
        console.log(`Total ${total}, ${passed} passed, ${skipped} skipped, ${failed} failed`);
      }
      const seen = new Set();
      this.workers.forEach((task) => {
        if (!task || seen.has(task.file)) {
          return;
        }
        seen.add(task.file);
        const time = task?.getRuntimeSeconds() || 0;
        if (time >= this.slowThreshold) {
          console.log(`${task?.file} is slow. Taking ${time}s.`);
        }
      });
    }, 1000);
  }

  private timer: NodeJS.Timeout | undefined;

  override exit(): void {
    super.exit();
    clearInterval(this.timer);
    const {
      failed, passed, pending, skipped, total,
    } = this.getStats();
    console.log(`Total ${total}, ${passed} passed, ${skipped} skipped, ${failed} failed, ${pending} pending`);
    this.onExit.resolve();
  }
}
class TerminalUIReporter extends TestReporter {
  pending_stdout: string[] = [];

  stdout(...message: string[]) {
    this.pending_stdout.push(...message);
    this.statsStale = true;
    this.dispatchEvent(new Event('flush'));
  }

  start() {
    render(h(TerminalUI, { runner: this }), { incrementalRendering: true, exitOnCtrlC: false }).waitUntilExit().then(this.onExit.resolve);
    this.startSlowTimer();
  }

  override exit() {
    super.exit();
    process.stdin.setRawMode(false);
  }
}

export function createTestReporter(): TestReporter {
  if (isCI) {
    return new BasicReporter();
  } else {
    return new TerminalUIReporter();
  }
}

export function annotateFileWithURL(filePath: string) {
  if (supportColor) {
    const fileLink = link(filePath, new URL(`./test262/test262/test/${filePath}`, import.meta.url));
    return `${fileLink} ${link('[GitHub]', `https://github.com/tc39/test262/blob/main/test/${filePath}`)}`;
  }
  return filePath;
}
