import i18next from 'i18next';
import { escape, isEmpty } from 'lodash-es';
import { channel } from 'redux-saga';
import {
  call,
  cancel,
  delay,
  fork,
  getContext,
  put,
  select,
  take,
  takeEvery,
  takeLatest
} from 'redux-saga/effects';

import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import {
  buildChallenge,
  canBuildChallenge
} from '@freecodecamp/challenge-builder/build';

import { createFlashMessage } from '../../../components/Flash/redux';
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
import {
  bodySizeFits,
  getStringSizeInBytes,
  MAX_BODY_SIZE,
  standardizeRequestBody
} from '../../../utils/challenge-request-helpers';
import { playTone } from '../../../utils/tone';
import {
  challengeHasPreview,
  getTestRunner,
  isJavaScriptChallenge,
  updatePreview,
  updateProjectPreview
} from '../utils/build';
import {
  interruptCodeExecution,
  runPythonCode
} from '../utils/python-worker-handler';
import { fireConfetti } from '../../../utils/fire-confetti';
import callGA from '../../../analytics/call-ga';
import { actionTypes } from './action-types';
import {
  disableBuildOnError,
  executeChallengeComplete,
  initConsole,
  initLogs,
  logsToConsole,
  openModal,
  setProjectPreviewLoading,
  updateConsole,
  updateLogs,
  updateTests
} from './actions';
import { curriculumData } from '../../../services/curriculum-data';
import {
  challengeDataSelector,
  challengeMetaSelector,
  challengeTestsSelector,
  challengeHooksSelector,
  isBuildEnabledSelector,
  isExecutingSelector,
  portalDocumentSelector,
  isBlockNewlyCompletedSelector
} from './selectors';

// How long before bailing out of a preview.
const previewTimeout = 2500;

const LOGS_TO_IGNORE = [
  // https://cdn.tailwindcss.com
  'cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation',
  // https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4
  'The browser build of Tailwind CSS should not be used in production. To use Tailwind CSS in production, use the Tailwind CLI, Vite plugin, or PostCSS plugin: https://tailwindcss.com/docs/installation'
];

// when 'run tests' is clicked, do this first
function* executeCancellableChallengeSaga(payload) {
  const { challengeType, id, saveSubmissionToDB } = yield select(
    challengeMetaSelector
  );
  const { challengeFiles } = yield select(challengeDataSelector);

  // if canSaveToDB, see if body/code size is submittable
  if (saveSubmissionToDB) {
    const body = standardizeRequestBody({ id, challengeFiles, challengeType });
    const bodySizeInBytes = getStringSizeInBytes(body);

    if (!bodySizeFits(bodySizeInBytes)) {
      return yield put(
        createFlashMessage({
          type: 'danger',
          message: FlashMessages.ChallengeSubmitTooBig,
          variables: { 'max-size': MAX_BODY_SIZE, 'user-size': bodySizeInBytes }
        })
      );
    }
  }

  // executeChallenge with payload containing {showCompletionModal}
  const task = yield fork(executeChallengeSaga, payload);

  yield take(actionTypes.cancelTests);
  yield cancel(task);
}

export function* executeChallengeSaga({ payload }) {
  const isBuildEnabled = yield select(isBuildEnabledSelector);
  if (!isBuildEnabled) {
    return;
  }

  const consoleProxy = yield channel();

  try {
    yield put(initLogs());
    yield put(initConsole(i18next.t('learn.running-tests')));
    // reset tests to initial state
    const tests = (yield select(challengeTestsSelector)).map(
      ({ text, testString }) => ({ text, testString, running: true })
    );
    const hooks = yield select(challengeHooksSelector);
    yield put(updateTests(tests));

    const challengeData = yield select(challengeDataSelector);
    const challengeMeta = yield select(challengeMetaSelector);
    // The buildData is used even if there are build errors, so that lessons
    // with syntax errors can be tested.
    const buildData = yield buildChallengeData(challengeData, {
      preview: false,
      disableLoopProtectTests: challengeMeta.disableLoopProtectTests,
      disableLoopProtectPreview: challengeMeta.disableLoopProtectPreview,
      usesTestRunner: true
    });
    const testRunner = yield call(getTestRunner, { ...buildData, hooks });
    const testResults = yield executeTests(testRunner, tests);
    yield put(updateTests(testResults));

    const challengeComplete = testResults.every(test => test.pass && !test.err);
    const isBlockCompleted = yield select(isBlockNewlyCompletedSelector);
    if (challengeComplete) {
      playTone('tests-completed');
      if (isBlockCompleted && curriculumData.hasData) {
        fireConfetti();
      }
    } else {
      playTone('tests-failed');
      if (challengeMeta.certification === 'responsive-web-design') {
        yield call(callGA, {
          event: 'challenge_failed',
          challenge_id: challengeMeta.id,
          challenge_path: window?.location?.pathname,
          challenge_files: challengeData.challengeFiles
        });
      }
    }

    if (challengeComplete && payload?.showCompletionModal) {
      yield put(openModal('completion'));
    }
    yield put(updateConsole(i18next.t('learn.tests-completed')));
    yield put(logsToConsole(i18next.t('learn.console-output')));
  } catch (e) {
    yield put(updateConsole(e));
  } finally {
    yield put(executeChallengeComplete());
    consoleProxy.close();
  }
}

function* takeEveryConsole(channel) {
  // TODO: move all stringifying and escaping into the reducer so there is a
  // single place responsible for formatting the console output.
  yield takeEvery(channel, function* (args) {
    yield put(updateConsole(escape(args)));
  });
}

function* buildChallengeData(challengeData, options) {
  try {
    return yield call(buildChallenge, challengeData, options);
  } catch (e) {
    yield put(disableBuildOnError());
    throw e;
  }
}

export function* executeTests(testRunner, tests, testTimeout = 5000) {
  const testStrings = tests.map(test => test.testString);
  const rawResults = yield call(testRunner, testStrings, testTimeout);

  const testResults = [];
  for (let i = 0; i < rawResults.length; i++) {
    const { text, testString } = tests[i];
    const newTest = { text, testString, running: false };
    // only the first test outputs console.logs to avoid log duplication.
    const firstTest = i === 0;
    try {
      const { pass, err, logs = [] } = rawResults[i] || {};

      const logString = logs.map(log => log.msg).join('\n');
      if (firstTest && logString) {
        yield put(updateLogs(logString));
      }

      if (pass) {
        newTest.pass = true;
      } else {
        throw err;
      }
    } catch (err) {
      const { actual, expected, type } = err;

      newTest.message = text
        .replace('--fcc-expected--', expected)
        .replace('--fcc-actual--', actual);
      if (err === 'timeout') {
        newTest.err = 'Test timed out';
        newTest.message = `${newTest.message} (${newTest.err})`;
      } else {
        const { message, stack } = err;
        newTest.err = message + '\n' + stack;
        newTest.stack = stack;
      }

      if (
        type === 'IndentationError' ||
        type === 'SyntaxError' ||
        type === 'NameError'
      ) {
        const msgKey =
          type === 'IndentationError'
            ? 'learn.indentation-error'
            : 'learn.syntax-error';
        newTest.message = `<p>${i18next.t(msgKey)}</p>`;
      }

      const withIndex = newTest.message.replace(/<p>/, `<p>${i + 1}. `);
      yield put(updateConsole(withIndex));
    } finally {
      testResults.push(newTest);
    }
  }
  return testResults;
}

function* flush() {
  yield put(initLogs());
  yield put(initConsole(''));
}

function* previewChallengeSaga() {
  const isBuildEnabled = yield select(isBuildEnabledSelector);
  if (!isBuildEnabled) {
    return;
  }

  // the challenge execution will update the preview, so this saga doesn't
  // need to do anything.
  if (yield select(isExecutingSelector)) {
    return;
  }

  const logProxy = yield channel();
  const proxyLogger = args => {
    if (!LOGS_TO_IGNORE.some(msg => args === msg)) {
      logProxy.put(args);
    }
  };

  try {
    yield fork(takeEveryConsole, logProxy);

    const challengeData = yield select(challengeDataSelector);

    if (canBuildChallenge(challengeData)) {
      const challengeMeta = yield select(challengeMetaSelector);
      const buildData = yield buildChallengeData(challengeData, {
        preview: true,
        disableLoopProtectTests: challengeMeta.disableLoopProtectTests,
        disableLoopProtectPreview: challengeMeta.disableLoopProtectPreview
      });
      // If there's an error building the challenge then throwing it here will
      // let the user know there's a problem.
      if (buildData.error) throw buildData.error;

      // evaluate the user code in the preview frame or in the worker
      if (challengeHasPreview(challengeData)) {
        const document = yield getContext('document');
        const portalDocument = yield select(portalDocumentSelector);
        const finalDocument = portalDocument || document;

        // Python challenges do not use the preview frame, they use a web worker
        // to run the code. The UI is handled by the xterm component, so there
        // is no need to update the preview frame.
        if (
          challengeData.challengeType === challengeTypes.python ||
          challengeData.challengeType ===
            challengeTypes.multifilePythonCertProject ||
          challengeData.challengeType === challengeTypes.pyLab ||
          challengeData.challengeType === challengeTypes.dailyChallengePy
        ) {
          yield updatePython(challengeData);
        } else {
          yield call(updatePreview, buildData, finalDocument, proxyLogger);
        }
      } else if (isJavaScriptChallenge(challengeData)) {
        const runUserCode = yield call(getTestRunner, buildData);

        // Without an empty testString the testRunner just evaluates the user's
        // code allowing us to get the console logs.
        const results = yield call(runUserCode, [''], previewTimeout);

        if (!isEmpty(results)) {
          const logs = results[0].logs?.filter(
            log => !LOGS_TO_IGNORE.some(msg => log.msg === msg)
          );
          const output = logs?.map(log => log.msg).join('\n');

          yield put(updateConsole(output));
        }
      }
    }
  } catch (err) {
    if (err[0] === 'timeout') {
      // TODO: translate the error
      err[0] = `The code you have written is taking longer than the ${previewTimeout}ms our challenges allow. You may have created an infinite loop or need to write a more efficient algorithm`;
    }
    // If the preview fails, the most useful thing to do is to show the learner
    // what the error is. As such, we replace whatever is in the console with
    // the error message.
    yield put(initConsole(escape(err)));
  }
}

export function* updatePreviewSaga(action) {
  yield flush();
  if (action.type === actionTypes.updateFile) {
    yield delay(700);
  }
  const challengeData = yield select(challengeDataSelector);
  if (
    challengeData.challengeType === challengeTypes.python ||
    challengeData.challengeType === challengeTypes.multifilePythonCertProject ||
    challengeData.challengeType === challengeTypes.pyLab ||
    challengeData.challengeType === challengeTypes.dailyChallengePy
  ) {
    yield updatePython(challengeData);
  } else {
    // all other challenges have to recreate the preview
    yield previewChallengeSaga(action);
  }
}

function* updatePython(challengeData) {
  // TODO: refactor the build pipeline so that we have discrete, composable
  // functions to handle transforming code, embedding it and building the
  // final html. Then we can just use the transformation function here.
  const buildData = yield buildChallengeData(challengeData);
  interruptCodeExecution();
  const code = {
    contents: buildData.sources.index,
    editableContents: buildData.sources.editableContents
  };

  runPythonCode(code);
}

function* previewProjectSolutionSaga({ payload }) {
  yield put(setProjectPreviewLoading(true));
  try {
    if (!payload?.challengeData) return;
    const { challengeData } = payload;

    if (canBuildChallenge(challengeData)) {
      const buildData = yield buildChallengeData(challengeData);
      if (buildData.error) throw Error(buildData.error);

      if (challengeHasPreview(challengeData)) {
        const document = yield getContext('document');
        yield call(updateProjectPreview, buildData, document);
      } else {
        throw Error('Project does not have a preview');
      }
    }
  } catch (err) {
    console.error('Unable to show project preview');
    console.error(err);
  } finally {
    yield put(setProjectPreviewLoading(false));
  }
}

export function createExecuteChallengeSaga(types) {
  return [
    takeLatest(types.executeChallenge, executeCancellableChallengeSaga),
    takeLatest(
      [
        types.updateFile,
        types.challengeMounted,
        types.resetChallenge,
        types.previewMounted
      ],
      updatePreviewSaga
    ),
    takeLatest(types.projectPreviewMounted, previewProjectSolutionSaga)
  ];
}
