import { assert } from '@esm-bundle/chai'
import { HttpRequest, IHttpRequest } from '../../models/HttpRequest.js'
import { ISentRequest } from '../../models/SentRequest.js'
import { CookieJar } from '../../cookies/CookieJar.js'
import { IResponse } from '../../models/Response.js'
import { ErrorResponse, IErrorResponse } from '../../models/ErrorResponse.js'
import { IHttpStep } from '../../models/http-flows/HttpStep.js'
import { FlowValue } from './HttpFlowRunner.js'
import { FlowRequestDataEnum, FlowResponseDataEnum, FlowSourceEnum } from '../../models/http-flows/HttpFlows.js'
import {
  IReadDataSourceStep,
  ReadDataSourceStep,
  ReadDataSourceStepKind,
} from '../../models/http-flows/steps/data/ReadDataSourceStep.js'
import { IReadValueStep, ReadValueStep, ReadValueStepKind } from '../../models/http-flows/steps/data/ReadValueStep.js'
import { HttpCookie } from '../../models/HttpCookie.js'
import { HttpResponse } from '../../models/HttpResponse.js'
import { Headers } from '../../lib/headers/Headers.js'
import { DataExtractor } from '../../data/DataExtractor.js'
import { RequestDataExtractor } from '../../data/RequestDataExtractor.js'
import { SameSiteValue } from '../../cookies/CookieParser.js'
import { AsLowerCaseStepKind } from '../../models/http-flows/steps/transformation/AsLowerCaseStep.js'
import { AsUpperCaseStepKind } from '../../models/http-flows/steps/transformation/AsUpperCaseStep.js'
import { AsNumberStepKind } from '../../models/http-flows/steps/transformation/AsNumberStep.js'
import { IRoundStep, RoundStepKind } from '../../models/http-flows/steps/transformation/RoundStep.js'
import { ISubstringStep, SubstringStepKind } from '../../models/http-flows/steps/transformation/SubstringStep.js'
import { TrimStepKind } from '../../models/http-flows/steps/transformation/TrimStep.js'
import { EqualAssertionKind, IEqualAssertion } from '../../models/http-flows/steps/assertion/EqualAssertion.js'
import { INotEqualAssertion, NotEqualAssertionKind } from '../../models/http-flows/steps/assertion/NotEqualAssertion.js'
import { ApiSchemaValues } from '../../amf/ApiSchemaValues.js'
import {
  GreaterThanAssertionKind,
  IGreaterThanAssertion,
} from '../../models/http-flows/steps/assertion/GreaterThanAssertion.js'
import { ILessThanAssertion, LessThanAssertionKind } from '../../models/http-flows/steps/assertion/LessThanAssertion.js'
import { IIncludesAssertion, IncludesAssertionKind } from '../../models/http-flows/steps/assertion/IncludesAssertion.js'
import {
  INotIncludesAssertion,
  NotIncludesAssertionKind,
} from '../../models/http-flows/steps/assertion/NotIncludesAssertion.js'
import { OkAssertionKind } from '../../models/http-flows/steps/assertion/OkAssertion.js'
import { NotOkAssertionKind } from '../../models/http-flows/steps/assertion/NotOkAssertion.js'
import { IToBeAssertion, ToBeAssertionKind, ToBeTypes } from '../../models/http-flows/steps/assertion/ToBeAssertion.js'
import { INotToBeAssertion, NotToBeAssertionKind } from '../../models/http-flows/steps/assertion/NotToBeAssertion.js'
import { IsEmptyAssertionKind } from '../../models/http-flows/steps/assertion/IsEmptyAssertion.js'
import { IsNotEmptyAssertionKind } from '../../models/http-flows/steps/assertion/IsNotEmptyAssertion.js'
import { ILengthOfAssertion, LengthOfAssertionKind } from '../../models/http-flows/steps/assertion/LengthOfAssertion.js'
import { IReadDataStep, ReadDataStep, ReadDataStepKind } from '../../models/http-flows/steps/data/ReadDataStep.js'
import { ISetDataStep, SetDataStep, SetDataStepKind } from '../../models/http-flows/steps/data/SetDataStep.js'
import {
  ISetVariableStep,
  SetVariableStep,
  SetVariableStepKind,
} from '../../models/http-flows/steps/data/SetVariableStep.js'
import { ISetCookieStep, SetCookieStep, SetCookieStepKind } from '../../models/http-flows/steps/data/SetCookieStep.js'
import {
  DeleteCookieStep,
  DeleteCookieStepKind,
  IDeleteCookieStep,
} from '../../models/http-flows/steps/data/DeleteCookieStep.js'

export interface IStepValue {
  value: FlowValue
  type?: FlowRequestDataEnum | FlowResponseDataEnum
  bodyMime?: string
}

/**
 * A class that supports both assertions and flows.
 * The derived classes specializes in a specific user flow (either performing flow actions or running assertions).
 */
export class HttpFlows {
  /**
   * The cumulative list of all variables to be applied to the request and other properties.
   * The variables must be already processed for variables in values (evaluated).
   *
   * These variables are passed by reference. Changes made anywhere to the variables will result
   * with updating this list.
   */
  variables?: Record<string, string>

  /**
   * An instance of a cookie jar (store) to put/read cookies.
   */
  cookies?: CookieJar

  protected async runStep(
    request: ISentRequest | IHttpRequest,
    step: IHttpStep,
    input: IStepValue,
    response?: IResponse | IErrorResponse
  ): Promise<IStepValue> {
    switch (step.kind) {
      // data reading
      case ReadDataSourceStepKind:
        return this.readDataSource(request, step, response)
      case ReadValueStepKind:
        return this.readValue(input, step as IReadValueStep)
      case ReadDataStepKind:
        return this.runReadDataStep(input, step as IReadDataStep, request, response)
      case SetDataStepKind:
        return this.runSetDataStep(input, step as ISetDataStep)
      case SetVariableStepKind:
        return this.runSetVariableStep(input, step as ISetVariableStep)
      case SetCookieStepKind:
        return this.runSetCookieStep(input, step as ISetCookieStep, request)
      case DeleteCookieStepKind:
        return this.runDeleteCookieStep(input, step as IDeleteCookieStep, request)
      // transformations
      case AsLowerCaseStepKind:
        return this.toLowerCase(input)
      case AsUpperCaseStepKind:
        return this.toUpperCase(input)
      case AsNumberStepKind:
        return this.asNumber(input)
      case RoundStepKind:
        return this.round(input, step as IRoundStep)
      case TrimStepKind:
        return this.trim(input)
      case SubstringStepKind:
        return this.substring(input, step as ISubstringStep)
      // assertions
      case EqualAssertionKind:
        return this.assertEqual(input, step as IEqualAssertion)
      case NotEqualAssertionKind:
        return this.assertNotEqual(input, step as INotEqualAssertion)
      case GreaterThanAssertionKind:
        return this.assertGreaterThan(input, step as IGreaterThanAssertion)
      case LessThanAssertionKind:
        return this.assertLessThan(input, step as ILessThanAssertion)
      case IncludesAssertionKind:
        return this.assertIncludes(input, step as IIncludesAssertion)
      case NotIncludesAssertionKind:
        return this.assertNotIncludes(input, step as INotIncludesAssertion)
      case OkAssertionKind:
        return this.assertOk(input)
      case NotOkAssertionKind:
        return this.assertNotOk(input)
      case ToBeAssertionKind:
        return this.toBe(input, step as IToBeAssertion)
      case NotToBeAssertionKind:
        return this.notToBe(input, step as INotToBeAssertion)
      case IsEmptyAssertionKind:
        return this.isEmpty(input)
      case IsNotEmptyAssertionKind:
        return this.isNotEmpty(input)
      case LengthOfAssertionKind:
        return this.lengthOf(input, step as ILengthOfAssertion)
      default:
        return { value: undefined }
    }
  }

  /**
   * Produces the data source.
   */
  protected async readDataSource(
    request: ISentRequest | IHttpRequest,
    step: IReadDataSourceStep,
    response?: IResponse | IErrorResponse
  ): Promise<IStepValue> {
    const instance = new ReadDataSourceStep(step)
    if (instance.enabled === false || !instance.checkValidity()) {
      return { value: undefined }
    }
    const { source, data } = step
    if (data && [FlowRequestDataEnum.headers, FlowResponseDataEnum.headers].includes(data)) {
      return {
        value: DataExtractor.readHeaders(source === FlowSourceEnum.request ? request : response),
        type: data,
      }
    }
    if (data && [FlowRequestDataEnum.url, FlowResponseDataEnum.url].includes(data)) {
      return {
        value: request.method,
        type: data,
      }
    }

    if (source === FlowSourceEnum.request) {
      if (data === FlowRequestDataEnum.method) {
        return {
          value: request.method,
          type: data,
        }
      }
      if (data === FlowRequestDataEnum.body) {
        const instance = new HttpRequest(request)
        const value = await this.processBody(instance)
        return {
          ...value,
          type: data,
        }
      }
    }
    if (source === FlowSourceEnum.response) {
      if (!response) {
        return {
          type: data,
          value: undefined,
        }
      }
      if (data === FlowResponseDataEnum.status) {
        return {
          value: response.status,
          type: data,
        }
      }
      if (data === FlowResponseDataEnum.statusText) {
        return {
          value: response.statusText,
          type: data,
        }
      }
      if (data === FlowResponseDataEnum.body) {
        let instance: ErrorResponse | HttpResponse
        if (ErrorResponse.isErrorResponse(response)) {
          instance = new ErrorResponse(response as IErrorResponse)
        } else {
          instance = new HttpResponse(response)
        }
        const value = await this.processBody(instance)
        return {
          ...value,
          type: data,
        }
      }
    }
    return { value: undefined }
  }

  protected async processBody(input: HttpRequest | HttpResponse | ErrorResponse): Promise<IStepValue> {
    const { headers } = input
    if (!headers) {
      return { value: undefined }
    }
    const body = await input.readPayloadAsString()
    if (!body) {
      return { value: undefined }
    }

    const parser = new Headers(headers)
    const mime = parser.get('content-type')
    if (!mime) {
      return { value: undefined }
    }
    return {
      value: body,
      bodyMime: mime,
    }
  }

  /**
   * Produces the value from the current input.
   */
  protected async readValue(input: IStepValue, step: IReadValueStep): Promise<IStepValue> {
    const instance = new ReadValueStep(step)
    if (instance.enabled === false || !instance.checkValidity() || input.value === undefined || input.value === null) {
      return { value: undefined }
    }
    const { path } = step
    const { type, value } = input
    const args = path ? path.split('.') : []
    if (type === FlowRequestDataEnum.url || type === FlowResponseDataEnum.url) {
      if (!value) {
        return { value: undefined }
      }
      const v = String(input.value)
      return {
        value: DataExtractor.getDataUrl(v, args),
      }
    }
    if (type === FlowRequestDataEnum.headers || type === FlowResponseDataEnum.headers) {
      if (!value) {
        return { value: undefined }
      }
      const v = String(input.value)
      return {
        value: DataExtractor.getDataHeaders(v, args),
      }
    }
    if (type === FlowRequestDataEnum.body || type === FlowResponseDataEnum.body) {
      if (!input.bodyMime || !path) {
        return { value: undefined }
      }
      const readValue = await DataExtractor.extractBody(String(value), input.bodyMime, path)
      return {
        value: readValue,
      }
    }
    if (type === FlowResponseDataEnum.status) {
      const v = Number(input.value)
      if (Number.isNaN(v)) {
        return { value: undefined }
      }
      return {
        value: v,
      }
    }

    if (type === FlowResponseDataEnum.statusText) {
      if (!value) {
        return { value: undefined }
      }
      return {
        value,
      }
    }
    return { value: undefined }
  }

  protected async toLowerCase(input: IStepValue): Promise<IStepValue> {
    if (input.value === null || input.value === undefined) {
      return { value: undefined }
    }
    const value = String(input.value)
    return { value: value.toLowerCase() }
  }

  protected async toUpperCase(input: IStepValue): Promise<IStepValue> {
    if (input.value === null || input.value === undefined) {
      return { value: undefined }
    }
    const value = String(input.value)
    return { value: value.toUpperCase() }
  }

  protected async asNumber(input: IStepValue): Promise<IStepValue> {
    if (input.value === null || input.value === undefined) {
      return { value: undefined }
    }
    const value = String(input.value)
    const parsed = Number(value)
    if (Number.isNaN(parsed)) {
      return { value: undefined }
    }
    return { value: parsed }
  }

  protected async round(input: IStepValue, step: IRoundStep): Promise<IStepValue> {
    if (input.value === null || input.value === undefined) {
      return { value: undefined }
    }
    const value = String(input.value)
    const parsed = typeof input.value === 'number' ? input.value : Number(value)
    if (Number.isNaN(parsed)) {
      return { value: undefined }
    }
    return { value: parsed.toFixed(step.precision) }
  }

  protected async trim(input: IStepValue): Promise<IStepValue> {
    if (input.value === null || input.value === undefined) {
      return { value: undefined }
    }
    const value = String(input.value)
    return { ...input, value: value.trim() }
  }

  protected async substring(input: IStepValue, step: ISubstringStep): Promise<IStepValue> {
    if (input.value === null || input.value === undefined) {
      return { value: undefined }
    }
    if (typeof step.start !== 'number') {
      return input
    }
    const value = String(input.value)
    return { ...input, value: value.substring(step.start, step.end) }
  }

  protected async assertEqual(input: IStepValue, step: IEqualAssertion): Promise<IStepValue> {
    const { value } = input
    if (step.strict) {
      const comparator = ApiSchemaValues.readTypedValue(step.value, step.dataType)
      assert.strictEqual(value, comparator)
    } else {
      assert.equal(String(value), step.value)
    }
    return input
  }

  protected async assertNotEqual(input: IStepValue, step: INotEqualAssertion): Promise<IStepValue> {
    const { value } = input
    if (step.strict) {
      const comparator = ApiSchemaValues.readTypedValue(step.value, step.dataType)
      assert.notStrictEqual(value, comparator)
    } else {
      assert.notEqual(value, step.value)
    }
    return input
  }

  protected async assertGreaterThan(input: IStepValue, step: IGreaterThanAssertion): Promise<IStepValue> {
    assert.typeOf(input.value, 'number')
    const value = input.value as number
    if (step.equal) {
      assert.isAtLeast(value as number, step.value as number)
    } else {
      assert.isAbove(value as number, step.value as number)
    }
    return input
  }

  protected async assertLessThan(input: IStepValue, step: ILessThanAssertion): Promise<IStepValue> {
    assert.typeOf(input.value, 'number')
    const value = input.value as number
    if (step.equal) {
      assert.isAtMost(value, step.value as number)
    } else {
      assert.isBelow(value, step.value as number)
    }
    return input
  }

  protected async assertIncludes(input: IStepValue, step: IIncludesAssertion): Promise<IStepValue> {
    if (this.isPrimitive(input.value)) {
      assert.include(String(input.value), String(step.value))
    } else {
      assert.include(JSON.stringify(input.value), String(step.value))
    }
    return input
  }

  protected async assertNotIncludes(input: IStepValue, step: INotIncludesAssertion): Promise<IStepValue> {
    if (this.isPrimitive(input.value)) {
      assert.notInclude(String(input.value), String(step.value))
    } else {
      assert.notInclude(JSON.stringify(input.value), String(step.value))
    }
    return input
  }

  protected async assertOk(input: IStepValue): Promise<IStepValue> {
    assert.ok(input.value)
    return input
  }

  protected async assertNotOk(input: IStepValue): Promise<IStepValue> {
    assert.notOk(input.value)
    return input
  }

  protected async toBe(input: IStepValue, step: IToBeAssertion): Promise<IStepValue> {
    assert.typeOf(input.value, step.value as string)
    return input
  }

  protected async notToBe(input: IStepValue, step: INotToBeAssertion): Promise<IStepValue> {
    if (Array.isArray(input.value) && step.value === ToBeTypes.object) {
      assert(false, 'expected [Array] to be an object')
    }
    assert.notTypeOf(input.value, step.value as string)
    return input
  }

  protected async isEmpty(input: IStepValue): Promise<IStepValue> {
    assert.isEmpty(input.value)
    return input
  }

  protected async isNotEmpty(input: IStepValue): Promise<IStepValue> {
    assert.isNotEmpty(input.value)
    return input
  }

  protected async lengthOf(input: IStepValue, step: ILengthOfAssertion): Promise<IStepValue> {
    assert.lengthOf(input.value as unknown[], step.value as number)
    return input
  }

  protected isPrimitive(value: unknown): boolean {
    return (typeof value !== 'object' && typeof value !== 'function') || value === null
  }

  /**
   * A step that reads data from the request, response, or variables.
   *
   * @param step The step configuration.
   * @param request The HttpRequest or the SentRequest (for responses)
   * @param response When available, the response data
   * @returns The read value or undefined.
   */
  async runReadDataStep(
    input: IStepValue,
    step: IReadDataStep,
    request: IHttpRequest | ISentRequest,
    response?: IResponse | IErrorResponse
  ): Promise<IStepValue> {
    const instance = new ReadDataStep(step)

    if (instance.enabled === false || !instance.checkValidity()) {
      return input
    }
    const { source, data, path } = step
    if (source === FlowSourceEnum.request) {
      const extractor = new RequestDataExtractor(request)
      const value = await extractor.extract(source, data, path)
      return { ...input, value }
    }

    if (source === FlowSourceEnum.response) {
      const extractor = new RequestDataExtractor(request, response)
      const value = await extractor.extract(source, data, path)
      return { ...input, value }
    }

    if (source === FlowSourceEnum.variable && path && this.variables) {
      const value = this.variables[path]
      return { ...input, value }
    }

    return { ...input, value: undefined }
  }

  /**
   * A step that returns a pre-configured value.
   *
   * @param step The step configuration.
   * @returns The read value with applied data type.
   */
  runSetDataStep(input: IStepValue, step: ISetDataStep): IStepValue {
    const instance = new SetDataStep(step)
    if (instance.enabled === false || !instance.checkValidity()) {
      return input
    }
    const { value, dataType } = step
    if (value === null || value === undefined) {
      return { ...input, value: undefined }
    }
    const result = ApiSchemaValues.parseScalar(value, dataType)
    return {
      ...input,
      value: result,
    }
  }

  /**
   * A step that sets a variable in the current environment.
   * Note, this passes the step value through.
   *
   * @param step The step configuration.
   * @param value The value to set. A value that is `null` or `undefined` deletes the variable.
   */
  runSetVariableStep(input: IStepValue, step: ISetVariableStep): IStepValue {
    const instance = new SetVariableStep(step)
    if (instance.enabled === false || !instance.checkValidity() || !this.variables) {
      return input
    }
    if (input.value === null || input.value === undefined) {
      return input
    }
    const { name } = step
    this.variables[name] = String(input.value)
    return input
  }

  /**
   * A step that sets a cookie.
   * Note, this passes the step value through.
   *
   * @param step The step configuration.
   * @param value The value to sent on the cookie.
   * @param request The request object.
   */
  async runSetCookieStep(
    input: IStepValue,
    step: ISetCookieStep,
    request: IHttpRequest | ISentRequest
  ): Promise<IStepValue> {
    const instance = new SetCookieStep(step)
    const { cookies } = this
    if (instance.enabled === false || !instance.checkValidity() || !cookies) {
      return input
    }
    if (input.value === null || input.value === undefined) {
      return input
    }
    const { name, expires, hostOnly, httpOnly, sameSite, secure, session } = step
    const typedValue = String(input.value)
    let url: string
    if (step.url) {
      url = step.url
    } else {
      url = request.url
    }
    const cookie = new HttpCookie({
      name: name,
      value: typedValue,
      sameSite: 'unspecified',
    })

    if (sameSite) {
      const typed = sameSite as SameSiteValue
      switch (typed.toLowerCase()) {
        case 'lax':
          cookie.sameSite = 'lax'
          break
        case 'strict':
          cookie.sameSite = 'strict'
          break
        case 'none':
          cookie.sameSite = 'no_restriction'
          break
      }
    }
    if (expires) {
      cookie.expirationDate = expires
    }
    if (typeof hostOnly === 'boolean') {
      cookie.hostOnly = hostOnly
    }
    if (typeof httpOnly === 'boolean') {
      cookie.httpOnly = httpOnly
    }
    if (typeof secure === 'boolean') {
      cookie.secure = secure
    }
    if (typeof session === 'boolean') {
      cookie.session = session
    }
    await cookies.setCookies(url, [cookie])
    return input
  }

  /**
   * A step to delete cookies from the cookie jar.
   *
   * Note, this passes the step value through.
   */
  async runDeleteCookieStep(
    input: IStepValue,
    step: IDeleteCookieStep,
    request: IHttpRequest | ISentRequest
  ): Promise<IStepValue> {
    const instance = new DeleteCookieStep(step)
    const { cookies } = this
    if (instance.enabled === false || !instance.checkValidity() || !cookies) {
      return input
    }
    let url: string
    if (step.url) {
      url = step.url
    } else {
      url = request.url
    }
    await cookies.deleteCookies(url, step.name)
    return input
  }
}
