import {
  Cookie,
  Environment,
  HoppCollectionVariable,
  HoppRESTHeader,
  HoppRESTHeaders,
  HoppRESTRequest,
  HoppRESTRequestVariable,
} from "@hoppscotch/data"
import {
  SandboxPreRequestResult,
  SandboxTestResult,
  TestDescriptor,
  TestResult,
} from "@hoppscotch/js-sandbox"
import * as A from "fp-ts/Array"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { flow, pipe } from "fp-ts/function"
import { cloneDeep } from "lodash-es"
import { Observable, Subject } from "rxjs"
import { filter } from "rxjs/operators"
import { Ref } from "vue"

import { map } from "fp-ts/Either"

import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web"
import { useSetting } from "~/composables/settings"
import { getService } from "~/modules/dioc"
import {
  environmentsStore,
  getCurrentEnvironment,
  getEnvironment,
  getGlobalVariables,
  setGlobalEnvVariables,
  updateEnvironment,
} from "~/newstore/environments"
import { platform } from "~/platform"
import { CookieJarService } from "~/services/cookie-jar.service"
import {
  CurrentValueService,
  Variable,
} from "~/services/current-environment-value.service"
import {
  SecretEnvironmentService,
  SecretVariable,
} from "~/services/secret-environment.service"
import { HoppTab } from "~/services/tab"
import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment"
import { createRESTNetworkRequestStream } from "./network"
import { HoppRequestDocument } from "./rest/document"
import {
  getTemporaryVariables,
  setTemporaryVariables,
} from "./runner/temp_envs"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { getCombinedEnvVariables } from "./utils/environments"
import {
  OutgoingSandboxPostRequestWorkerMessage,
  OutgoingSandboxPreRequestWorkerMessage,
} from "./workers/sandbox.worker"
import { transformInheritedCollectionVariablesToAggregateEnv } from "./utils/inheritedCollectionVarTransformer"
import { isJSONContentType } from "./utils/contenttypes"

const sandboxWorker = new Worker(
  new URL("./workers/sandbox.worker.ts", import.meta.url),
  {
    type: "module",
  }
)

const secretEnvironmentService = getService(SecretEnvironmentService)
const currentEnvironmentValueService = getService(CurrentValueService)
const cookieJarService = getService(CookieJarService)

const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
  "EXPERIMENTAL_SCRIPTING_SANDBOX"
)

export const getTestableBody = (
  res: HoppRESTResponse & { type: "success" | "fail" }
) => {
  const contentTypeHeader = res.headers.find(
    (h: HoppRESTHeader) => h.key.toLowerCase() === "content-type"
  )

  const rawBody = new TextDecoder("utf-8")
    .decode(res.body)
    .replaceAll("\x00", "")

  const x = pipe(
    // This pipeline just decides whether JSON parses or not
    contentTypeHeader && isJSONContentType(contentTypeHeader.value)
      ? O.of(rawBody)
      : O.none,

    // Try parsing, if failed, go to the fail option
    O.chain((body) => O.tryCatch(() => JSON.parse(body))),

    // If JSON, return that (get), else return just the body string (else)
    O.getOrElse<any | string>(() => rawBody)
  )

  return x
}

/**
 * Combines the environment variables from the request and the selected, global, and temporary environments.
 * The priority is as follows:
 * 1. Request variables
 * 2. Temporary variables (if any)
 * 3. Selected environment variables
 * 4. Global environment variables
 * @param variables The environment variables to combine
 * @returns The combined environment variables
 */
export const combineEnvVariables = (variables: {
  environments: {
    selected: Environment["variables"]
    global: Environment["variables"]
    temp?: Environment["variables"]
  }
  requestVariables: Environment["variables"]
  collectionVariables: Environment["variables"]
}) => [
  ...variables.requestVariables,
  ...variables.collectionVariables,
  ...(variables.environments.temp ?? []),
  ...variables.environments.selected,
  ...variables.environments.global,
]

export const executedResponses$ = new Subject<
  HoppRESTResponse & { type: "success" | "fail " }
>()

/**
 * This will update the environment variables in the current environment
 * and secret environment service.
 * @param envs The environment variables to update
 * @param type Whether the environment variables are global or selected
 * @returns the updated environment variables
 */
const updateEnvironments = (
  envs: Environment["variables"] &
    {
      secret: true
      currentValue: string
      initialValue: string
      key: string
    }[],
  type: "global" | "selected"
) => {
  const currentEnvID =
    type === "selected" ? getCurrentEnvironment().id : "Global"

  const updatedSecretEnvironments: SecretVariable[] = []
  const nonSecretVariables: Variable[] = []

  const updatedEnv = pipe(
    envs,
    A.mapWithIndex((index, e) => {
      if (e.secret) {
        updatedSecretEnvironments.push({
          key: e.key,
          value: e.currentValue ?? "",
          varIndex: index,
        })

        // delete the value from the environment
        // so that it doesn't get saved in the environment

        return {
          key: e.key,
          secret: e.secret,
          initialValue: e.initialValue ?? "",
          currentValue: "",
        }
      }

      nonSecretVariables.push({
        key: e.key,
        isSecret: e.secret ?? false,
        varIndex: index,
        currentValue: e.currentValue ?? "",
      })
      // set the current value as empty string
      // so that it doesn't get saved in the environment
      return {
        key: e.key,
        secret: e.secret,
        initialValue: e.initialValue ?? "",
        currentValue: "",
      }
    })
  )
  if (currentEnvID) {
    secretEnvironmentService.addSecretEnvironment(
      currentEnvID,
      updatedSecretEnvironments
    )

    currentEnvironmentValueService.addEnvironment(
      currentEnvID,
      nonSecretVariables
    )
  }
  return updatedEnv
}

/**
 * Get the environment variable value from the current environment
 * @param envID The environment ID
 * @param index The index of the environment variable
 * @param isSecret Whether the environment variable is a secret
 * @returns The environment variable value
 */
const getEnvironmentVariableValue = (
  envID: string,
  index: number,
  isSecret: boolean
): string | undefined => {
  if (isSecret) {
    return secretEnvironmentService.getSecretEnvironmentVariableValue(
      envID,
      index
    )
  }
  return currentEnvironmentValueService.getEnvironmentVariableValue(
    envID,
    index
  )
}

/**
 * Set currentValue as initialValue if currentValue is empty
 * This is set just for request runtime and it will not be persisted.
 * @param env The environment variable to be transformed
 * @returns The transformed environment variable with currentValue set to initialValue if empty
 */
const getTransformedEnvs = (
  env: Environment["variables"][number]
): Environment["variables"][number] => {
  return {
    ...env,
    currentValue: env.currentValue || env.initialValue,
  }
}

/**
 * Transforms the environment list to a list with unique keys with value
 * and set currentValue as initialValue if currentValue is empty.
 * @param envs The environment list to be transformed
 * @returns The transformed environment list with keys with value
 */
const filterNonEmptyEnvironmentVariables = (
  envs: Environment["variables"]
): Environment["variables"] => {
  const envsMap = new Map<string, Environment["variables"][number]>()
  envs.forEach((env) => {
    const transformedEnv = getTransformedEnvs(env)

    if (envsMap.has(transformedEnv.key)) {
      const existingEnv = envsMap.get(transformedEnv.key)

      if (
        existingEnv &&
        "currentValue" in existingEnv &&
        existingEnv.currentValue === "" &&
        transformedEnv.currentValue !== ""
      ) {
        envsMap.set(transformedEnv.key, transformedEnv)
      }
    } else {
      envsMap.set(transformedEnv.key, transformedEnv)
    }
  })

  return Array.from(envsMap.values())
}

const delegatePreRequestScriptRunner = (
  request: HoppRESTRequest,
  envs: {
    global: Environment["variables"]
    selected: Environment["variables"]
    temp: Environment["variables"]
  },
  cookies: Cookie[] | null
): Promise<E.Either<string, SandboxPreRequestResult>> => {
  const { preRequestScript } = request

  if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) {
    return runPreRequestScript(preRequestScript, {
      envs,
      experimentalScriptingSandbox: false,
    })
  }

  return new Promise((resolve) => {
    const handleMessage = (
      event: MessageEvent<OutgoingSandboxPreRequestWorkerMessage>
    ) => {
      if (event.data.type === "PRE_REQUEST_SCRIPT_ERROR") {
        const error =
          event.data.data instanceof Error
            ? event.data.data.message
            : String(event.data.data)

        sandboxWorker.removeEventListener("message", handleMessage)
        resolve(E.left(error))
      }

      if (event.data.type === "PRE_REQUEST_SCRIPT_RESULT") {
        sandboxWorker.removeEventListener("message", handleMessage)
        resolve(event.data.data)
      }
    }

    sandboxWorker.addEventListener("message", handleMessage)

    sandboxWorker.postMessage({
      type: "pre",
      envs,
      request: JSON.stringify(request),
      cookies: cookies ? JSON.stringify(cookies) : null,
    })
  })
}

const runPostRequestScript = (
  envs: TestResult["envs"],
  request: HoppRESTRequest,
  response: HoppRESTResponse,
  cookies: Cookie[] | null
): Promise<E.Either<string, SandboxTestResult>> => {
  const { testScript } = request

  if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) {
    return runTestScript(testScript, {
      envs,
      response,
      experimentalScriptingSandbox: false,
    })
  }

  return new Promise((resolve) => {
    const handleMessage = (
      event: MessageEvent<OutgoingSandboxPostRequestWorkerMessage>
    ) => {
      if (event.data.type === "POST_REQUEST_SCRIPT_ERROR") {
        const error =
          event.data.data instanceof Error
            ? event.data.data.message
            : String(event.data.data)

        sandboxWorker.removeEventListener("message", handleMessage)
        resolve(E.left(error))
      }

      if (event.data.type === "POST_REQUEST_SCRIPT_RESULT") {
        sandboxWorker.removeEventListener("message", handleMessage)
        resolve(event.data.data)
      }
    }

    sandboxWorker.addEventListener("message", handleMessage)

    sandboxWorker.postMessage({
      type: "post",
      envs,
      request: JSON.stringify(request),
      response,
      cookies: cookies ? JSON.stringify(cookies) : null,
    })
  })
}

export function runRESTRequest$(
  tab: Ref<HoppTab<HoppRequestDocument>>
): [
  () => void,
  Promise<
    | E.Left<"script_fail" | "cancellation">
    | E.Right<Observable<HoppRESTResponse>>
  >,
] {
  let cancelCalled = false
  let cancelFunc: (() => void) | null = null

  const cancel = () => {
    cancelCalled = true
    cancelFunc?.()
  }

  const cookieJarEntries = getCookieJarEntries()

  const { request, inheritedProperties } = tab.value.document

  const requestAuth =
    request.auth.authType === "inherit" && request.auth.authActive
      ? inheritedProperties?.auth.inheritedAuth
      : request.auth

  const inheritedHeaders = inheritedProperties?.headers
    ?.filter((header) => header.inheritedHeader)
    .map((header) => header.inheritedHeader!)

  const requestHeaders: HoppRESTHeaders = [
    ...(inheritedHeaders ?? []),
    ...request.headers,
  ]

  const resolvedRequest = {
    ...tab.value.document.request,
    auth: requestAuth ?? { authType: "none", authActive: false },
    headers: requestHeaders,
  }

  const res = delegatePreRequestScriptRunner(
    resolvedRequest,
    getCombinedEnvVariables(),
    cookieJarEntries
  ).then(async (preRequestScriptResult) => {
    if (cancelCalled) return E.left("cancellation" as const)

    if (E.isLeft(preRequestScriptResult)) {
      console.error(preRequestScriptResult.left)
      return E.left("script_fail" as const)
    }

    const finalRequestVariables =
      tab.value.document.request.requestVariables.map(
        (v: HoppRESTRequestVariable) => {
          if (v.active) {
            return {
              key: v.key,
              initialValue: v.value,
              currentValue: v.value,
              secret: false,
            }
          }
          return []
        }
      )

    const collectionVariables =
      transformInheritedCollectionVariablesToAggregateEnv(
        tab.value.document.inheritedProperties?.variables || []
      ).map(({ key, initialValue, currentValue, secret }) => ({
        key,
        initialValue,
        currentValue,
        secret,
      }))

    const finalRequest = {
      ...resolvedRequest,
      ...(preRequestScriptResult.right.updatedRequest ?? {}),
    }

    // Propagate changes to request variables from the scripting context to the UI
    tab.value.document.request.requestVariables = finalRequest.requestVariables

    const finalEnvs = {
      environments: preRequestScriptResult.right.updatedEnvs,
      requestVariables: finalRequestVariables as Environment["variables"],
      collectionVariables,
    }

    const finalEnvsWithNonEmptyValues = filterNonEmptyEnvironmentVariables(
      combineEnvVariables(finalEnvs)
    )

    const effectiveRequest = await getEffectiveRESTRequest(finalRequest, {
      id: "env-id",
      v: 2,
      name: "Env",
      variables: finalEnvsWithNonEmptyValues,
    })

    const [stream, cancelRun] =
      await createRESTNetworkRequestStream(effectiveRequest)
    cancelFunc = cancelRun

    const subscription = stream
      .pipe(filter((res) => res.type === "success" || res.type === "fail"))
      .subscribe(async (res) => {
        if (res.type === "success" || res.type === "fail") {
          executedResponses$.next(res)

          const postRequestScriptResult = await runPostRequestScript(
            preRequestScriptResult.right.updatedEnvs,
            res.req,
            {
              status: res.statusCode,
              body: getTestableBody(res),
              headers: res.headers,
              statusText: res.statusText,
              responseTime: res.meta.responseDuration,
            },
            preRequestScriptResult.right.updatedCookies ?? null
          )

          if (E.isRight(postRequestScriptResult)) {
            // set the response in the tab so that multiple tabs can run request simultaneously
            tab.value.document.response = res

            // Combine console entries from pre and post request scripts
            const combinedResult = pipe(
              postRequestScriptResult,
              map((result) => ({
                ...result,
                consoleEntries: [
                  ...(preRequestScriptResult.right.consoleEntries ?? []),
                  ...(result.consoleEntries ?? []),
                ],
              }))
            ) as E.Right<SandboxTestResult>

            tab.value.document.testResults = translateToSandboxTestResults(
              combinedResult.right
            )
            updateEnvsAfterTestScript(combinedResult)

            const updatedCookies = postRequestScriptResult.right.updatedCookies

            if (updatedCookies) {
              const newCookieMap = new Map<string, Cookie[]>()

              for (const cookie of updatedCookies) {
                const domain = cookie.domain

                if (!newCookieMap.has(domain)) {
                  newCookieMap.set(domain, [])
                }

                newCookieMap.get(domain)!.push(cookie)
              }

              cookieJarService.cookieJar.value = newCookieMap
            }
          } else {
            tab.value.document.testResults = {
              description: "",
              expectResults: [],
              tests: [],
              envDiff: {
                global: {
                  additions: [],
                  deletions: [],
                  updations: [],
                },
                selected: {
                  additions: [],
                  deletions: [],
                  updations: [],
                },
              },
              scriptError: true,
              consoleEntries: [],
            }
          }

          subscription.unsubscribe()
        }
      })

    return E.right(stream)
  })

  return [cancel, res]
}

function updateEnvsAfterTestScript(runResult: E.Right<SandboxTestResult>) {
  const globalEnvVariables = updateEnvironments(
    // @ts-expect-error Typescript can't figure out this inference for some reason
    runResult.right.envs.global,
    "global"
  )

  setGlobalEnvVariables({
    v: 2,
    variables: globalEnvVariables,
  })
  const selectedEnvVariables = updateEnvironments(
    // @ts-expect-error Typescript can't figure out this inference for some reason
    cloneDeep(runResult.right.envs.selected),
    "selected"
  )
  if (environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV") {
    const env = getEnvironment({
      type: "MY_ENV",
      index: environmentsStore.value.selectedEnvironmentIndex.index,
    })
    updateEnvironment(environmentsStore.value.selectedEnvironmentIndex.index, {
      name: env.name,
      v: 2,
      id: "id" in env ? env.id : "",
      variables: selectedEnvVariables,
    })
  } else if (
    environmentsStore.value.selectedEnvironmentIndex.type === "TEAM_ENV"
  ) {
    const env = getEnvironment({
      type: "TEAM_ENV",
    })
    pipe(
      updateTeamEnvironment(
        JSON.stringify(selectedEnvVariables),
        environmentsStore.value.selectedEnvironmentIndex.teamEnvID,
        env.name
      )
    )()
  }
}

const getCookieJarEntries = () => {
  // Exclusive to the Desktop App
  if (!platform.platformFeatureFlags.cookiesEnabled) {
    return null
  }

  const cookieJarEntries = Array.from(
    cookieJarService.cookieJar.value.values()
  ).flatMap((cookies) => cookies)

  return cookieJarEntries
}

/**
 * Run the test runner request
 * @param request The request to run
 * @param persistEnv Whether to persist the environment variables after running the test script
 * @returns The response and the test result
 */

export function runTestRunnerRequest(
  request: HoppRESTRequest,
  persistEnv = true,
  inheritedVariables: HoppCollectionVariable[] = []
): Promise<
  | E.Left<"script_fail">
  | E.Right<{
      response: HoppRESTResponse
      testResult: HoppTestResult
      updatedRequest: HoppRESTRequest
    }>
  | undefined
> {
  const cookieJarEntries = getCookieJarEntries()

  return delegatePreRequestScriptRunner(
    request,
    getCombinedEnvVariables(),
    cookieJarEntries
  ).then(async (preRequestScriptResult) => {
    if (E.isLeft(preRequestScriptResult)) {
      console.error(preRequestScriptResult.left)
      return E.left("script_fail" as const)
    }

    const finalRequestVariables = pipe(
      request.requestVariables,
      A.filter(({ active }) => active),
      A.map(({ key, value }) => ({
        key,
        initialValue: value,
        currentValue: value,
        secret: false,
      }))
    )

    // Calculate the final updated request after pre-request script changes
    const finalRequest = {
      ...request,
      ...(preRequestScriptResult.right.updatedRequest ?? {}),
    }

    const effectiveRequest = await getEffectiveRESTRequest(finalRequest, {
      id: "env-id",
      v: 2,
      name: "Env",
      variables: filterNonEmptyEnvironmentVariables(
        combineEnvVariables({
          environments: {
            ...preRequestScriptResult.right.updatedEnvs,
            temp: !persistEnv ? getTemporaryVariables() : [],
          },
          requestVariables: finalRequestVariables,
          collectionVariables: inheritedVariables,
        })
      ),
    })

    const [stream] = createRESTNetworkRequestStream(effectiveRequest)

    const requestResult = stream
      .pipe(filter((res) => res.type === "success" || res.type === "fail"))
      .toPromise()
      .then(async (res) => {
        if (res?.type === "success" || res?.type === "fail") {
          executedResponses$.next(res)

          const postRequestScriptResult = await runPostRequestScript(
            preRequestScriptResult.right.updatedEnvs,
            res.req,
            {
              status: res.statusCode,
              body: getTestableBody(res),
              headers: res.headers,
              statusText: res.statusText,
              responseTime: res.meta.responseDuration,
            },
            preRequestScriptResult.right.updatedCookies ?? null
          )

          if (E.isRight(postRequestScriptResult)) {
            // Combine console entries from pre and post request scripts
            const combinedResult = {
              ...postRequestScriptResult.right,
              consoleEntries: [
                ...(preRequestScriptResult.right.consoleEntries ?? []),
                ...(postRequestScriptResult.right.consoleEntries ?? []),
              ],
            }

            const sandboxTestResult =
              translateToSandboxTestResults(combinedResult)

            // Update the environment variables after running the test script when persistEnv is true. else store the updated environment variables in the store as a temporary variable.
            if (persistEnv) {
              updateEnvsAfterTestScript(postRequestScriptResult)
            } else {
              // Combine global and selected environment changes
              const allChanges = [
                ...postRequestScriptResult.right.envs.global,
                ...postRequestScriptResult.right.envs.selected,
              ]

              setTemporaryVariables(allChanges)
            }

            return E.right({
              response: res,
              testResult: sandboxTestResult,
              updatedRequest: finalRequest,
            })
          }
          const sandboxTestResult = {
            description: "",
            expectResults: [],
            tests: [],
            envDiff: {
              global: {
                additions: [],
                deletions: [],
                updations: [],
              },
              selected: {
                additions: [],
                deletions: [],
                updations: [],
              },
            },
            scriptError: true,
            consoleEntries: [],
          }
          return E.right({
            response: res,
            testResult: sandboxTestResult,
            updatedRequest: finalRequest,
          })
        }
      })

    if (requestResult) {
      return requestResult
    }

    return E.left("script_fail")
  })
}

const getAddedEnvVariables = (
  current: Environment["variables"],
  updated: Environment["variables"]
) => updated.filter((x) => current.findIndex((y) => y.key === x.key) === -1)

const getRemovedEnvVariables = (
  current: Environment["variables"],
  updated: Environment["variables"]
) => current.filter((x) => updated.findIndex((y) => y.key === x.key) === -1)

const getUpdatedEnvVariables = (
  current: Environment["variables"],
  updated: Environment["variables"]
) =>
  pipe(
    updated,
    A.filterMap(
      flow(
        O.of,
        O.bindTo("env"),
        O.bind("index", ({ env }) =>
          pipe(
            current.findIndex((x) => x.key === env.key),
            O.fromPredicate((x) => x !== -1)
          )
        ),
        O.chain(
          O.fromPredicate(
            ({ env, index }) => env.currentValue !== current[index].currentValue
          )
        ),
        O.map(({ env, index }) => ({
          ...env,
          previousValue: current[index].currentValue,
        }))
      )
    )
  )

function translateToSandboxTestResults(
  testDesc: SandboxTestResult
): HoppTestResult {
  const translateChildTests = (child: TestDescriptor): HoppTestData => {
    return {
      description: child.descriptor,
      expectResults: child.expectResults,
      tests: child.children.map(translateChildTests),
    }
  }

  const globals = cloneDeep(getGlobalVariables()).map((g, index) => ({
    ...g,
    currentValue: getEnvironmentVariableValue("Global", index, g.secret) ?? "",
  }))

  const envVars = getCurrentEnvironment().variables.map((e, index) => ({
    ...e,
    currentValue:
      getEnvironmentVariableValue(
        getCurrentEnvironment().id,
        index,
        e.secret
      ) ?? "",
  }))

  return {
    description: "",
    expectResults: testDesc.tests.expectResults,
    tests: testDesc.tests.children.map(translateChildTests),
    scriptError: false,
    envDiff: {
      global: {
        additions: getAddedEnvVariables(globals, testDesc.envs.global),
        deletions: getRemovedEnvVariables(globals, testDesc.envs.global),
        updations: getUpdatedEnvVariables(globals, testDesc.envs.global),
      },
      selected: {
        additions: getAddedEnvVariables(envVars, testDesc.envs.selected),
        deletions: getRemovedEnvVariables(envVars, testDesc.envs.selected),
        updations: getUpdatedEnvVariables(envVars, testDesc.envs.selected),
      },
    },
    consoleEntries: testDesc.consoleEntries,
  }
}
