import {
  ComponentType,
  ConditionsModel,
  ControllerPath,
  ControllerType,
  SchemaVersion,
  convertConditionWrapperFromV2,
  formDefinitionSchema,
  formDefinitionV2Schema,
  generateConditionAlias,
  hasComponents,
  hasComponentsEvenIfNoNext,
  hasRepeater,
  isConditionWrapperV2,
  yesNoListId,
  yesNoListName,
  type ComponentDef,
  type ConditionWrapper,
  type ConditionWrapperV2,
  type ConditionsModelData,
  type DateUnits,
  type Engine,
  type FormDefinition,
  type List,
  type Page,
  type Section
} from '@defra/forms-model'
import { add, format } from 'date-fns'
import { Parser, type Value } from 'expr-eval-fork'
import joi from 'joi'

import { logger } from '../../../common/helpers/logging/logger.js'
import { type ListFormComponent } from '../components/ListFormComponent.js'
import {} from '../components/YesNoField.js'
import {
  hasListFormField,
  type Component
} from '../components/helpers/components.js'
import { todayAsDateOnly } from '../date-helper.js'
import {
  findPage,
  getError,
  getPage,
  setPageTitles
} from '../helpers.js'
import { type ExecutableCondition } from './types.js'
import { type PageController } from '../pageControllers/PageController.js'
import {
  createPage,
  type PageControllerClass
} from '../pageControllers/helpers/pages.js'
import { validationOptions as opts } from '../pageControllers/validationOptions.js'
import * as defaultServices from '../services/index.js'
import {
  type FormContext,
  type FormContextRequest,
  type FormState,
  type FormSubmissionError,
  type FormSubmissionState
} from '../types.js'
import { FormAction } from '../../../routes/types.js'
import { merge } from '../../../services/cacheService.js'
import { type Services } from '../../../types.js'

export class FormModel {
  /** The runtime engine that should be used */
  engine?: Engine

  schemaVersion: SchemaVersion

  /** the entire form JSON as an object */
  def: FormDefinition

  lists: FormDefinition['lists']
  sections: FormDefinition['sections'] = []
  name: string
  formId: string
  values: FormDefinition
  basePath: string
  ordnanceSurveyApiKey?: string
  conditions: Partial<Record<string, ExecutableCondition>>
  pages: PageControllerClass[]
  services: Services

  controllers?: Record<string, typeof PageController>
  pageDefMap: Map<string, Page>

  listDefMap: Map<string, List>
  listDefIdMap: Map<string, List>

  componentDefMap: Map<string, ComponentDef>
  componentDefIdMap: Map<string, ComponentDef>

  pageMap: Map<string, PageControllerClass>
  componentMap: Map<string, Component>

  constructor(
    def: typeof this.def,
    options: {
      basePath: string
      ordnanceSurveyApiKey?: string
      formId?: string
    },
    services: Services = defaultServices,
    controllers?: Record<string, typeof PageController>
  ) {
    let schema = formDefinitionV2Schema

    if (!def.schema || def.schema === SchemaVersion.V1) {
      logger.warn(
        `[DEPRECATION NOTICE] Form "${def.name}" constructed with legacy V1 schema. See https://defra.github.io/forms-engine-plugin/schemas/form-definition-schema.html.`
      )
      schema = formDefinitionSchema
    }

    const result = schema.validate(def, { abortEarly: false })

    if (result.error) {
      throw result.error
    }

    // Make a clone of the shallow copy returned
    // by joi so as not to change the source data.
    def = structuredClone(result.value)

    // Add default lists
    def.lists.push({
      id: def.schema === SchemaVersion.V1 ? yesNoListName : yesNoListId,
      name: '__yesNo',
      title: 'Yes/No',
      type: 'boolean',
      items: [
        {
          id: '02900d42-83d1-4c72-a719-c4e8228952fa',
          text: 'Yes',
          value: true
        },
        {
          id: 'f39000eb-c51b-4019-8f82-bbda0423f04d',
          text: 'No',
          value: false
        }
      ]
    })

    // Fix up page titles
    setPageTitles(def)

    this.engine = def.engine
    this.schemaVersion = def.schema ?? SchemaVersion.V1
    this.def = def
    this.lists = def.lists
    this.sections = def.sections
    this.name = def.name ?? ''
    this.formId = options.formId ?? ''
    this.values = result.value
    this.basePath = options.basePath
    this.ordnanceSurveyApiKey = options.ordnanceSurveyApiKey
    this.conditions = {}
    this.services = services
    this.controllers = controllers

    // Assert that there is only one payment question (if any)
    if (this.moreThanOnePaymentQuestion()) {
      throw new Error(
        'Invalid form definition: Only one payment question is allowed per form'
      )
    }

    this.pageDefMap = new Map(def.pages.map((page) => [page.path, page]))
    this.listDefMap = new Map(def.lists.map((list) => [list.name, list]))
    this.listDefIdMap = new Map(
      def.lists
        .filter((list) => list.id) // Skip lists without an ID
        // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
        .map((list) => [list.id as string, list])
    )
    this.componentDefMap = new Map(
      def.pages
        .filter(hasComponents)
        .flatMap((page) =>
          page.components.map((component) => [component.name, component])
        )
    )
    this.componentDefIdMap = new Map(
      def.pages.filter(hasComponents).flatMap((page) =>
        page.components
          .filter((component) => component.id) // Skip components without an ID
          .map((component) => {
            // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
            return [component.id as string, component]
          })
      )
    )

    def.conditions.forEach((conditionDef) => {
      const condition = this.makeCondition(
        isConditionWrapperV2(conditionDef)
          ? convertConditionWrapperFromV2(conditionDef, this)
          : conditionDef
      )
      this.conditions[condition.name] = condition
    })

    this.pages = def.pages.map((pageDef) => createPage(this, pageDef))

    if (
      !def.pages.some(
        ({ controller }) =>
          // Check for user-provided status page (optional)
          controller === ControllerType.Status
      )
    ) {
      this.pages.push(
        createPage(this, {
          title: 'Form submitted',
          path: ControllerPath.Status,
          controller: ControllerType.Status
        })
      )
    }

    this.pageMap = new Map(this.pages.map((page) => [page.path, page]))
    this.componentMap = new Map(
      this.pages.flatMap((page) =>
        page.collection.components.map((component) => [
          component.name,
          component
        ])
      )
    )
  }

  /**
   * build the entire model schema from individual pages/sections and filter out answers
   * for pages which are no longer accessible due to an answer that has been changed
   */
  makeFilteredSchema(relevantPages: PageControllerClass[]) {
    // Build the entire model schema
    // from the individual pages/sections
    let schema = joi.object<FormSubmissionState>().required()

    relevantPages.forEach((page) => {
      schema = schema.concat(page.collection.stateSchema)
    })

    return schema
  }

  /**
   * Instantiates a Condition based on {@link ConditionWrapper}
   * @param condition
   */
  makeCondition(condition: ConditionWrapper): ExecutableCondition {
    const parser = new Parser({
      operators: {
        logical: true
      }
    })

    Object.assign(parser.functions, {
      dateForComparison(timePeriod: number, timeUnit: DateUnits) {
        // The time element must be stripped (hence using startOfDay() which has no time element),
        // then formatted as YYYY-MM-DD otherwise we can hit time element and BST issues giving the
        // wrong date to compare against.
        // Do not use .toISOString() to format the date as that introduces BST errors.
        return format(
          add(todayAsDateOnly(), { [timeUnit]: timePeriod }),
          'yyyy-MM-dd'
        )
      }
    })

    const { name, displayName, value } = condition
    const expr = this.toConditionExpression(value, parser)

    const fn = (evaluationState: FormState) => {
      const ctx = this.toConditionContext(evaluationState, this.conditions)
      try {
        return expr.evaluate(ctx) as boolean
      } catch {
        return false
      }
    }

    return {
      name,
      displayName,
      value,
      expr,
      fn
    }
  }

  toConditionContext(
    evaluationState: FormState,
    conditions: Partial<Record<string, ExecutableCondition>>
  ) {
    const context = { ...evaluationState }

    for (const conditionId in conditions) {
      const propertyName =
        this.schemaVersion === SchemaVersion.V2
          ? generateConditionAlias(conditionId)
          : conditionId

      Object.defineProperty(context, propertyName, {
        get() {
          return conditions[conditionId]?.fn(evaluationState)
        }
      })
    }

    return context as Extract<Value, Record<string, Value>>
  }

  toConditionExpression(value: ConditionsModelData, parser: Parser) {
    const conditions = ConditionsModel.from(value)
    return parser.parse(conditions.toExpression())
  }

  getList(nameOrId: string): List | undefined {
    return this.schemaVersion === SchemaVersion.V1
      ? this.lists.find((list) => list.name === nameOrId)
      : this.lists.find((list) => list.id === nameOrId)
  }

  getSection(nameOrId: string): Section | undefined {
    return this.schemaVersion === SchemaVersion.V1
      ? this.sections.find((section) => section.name === nameOrId)
      : this.sections.find((section) => section.id === nameOrId)
  }

  /**
   * Form context for the current page
   */
  getFormContext(
    request: FormContextRequest,
    state: FormSubmissionState,
    errors?: FormSubmissionError[]
  ): FormContext {
    const { query } = request

    const page = getPage(this, request)

    // Determine form paths
    const currentPath = page.path
    const startPath = page.getStartPath()

    // Preview URL direct access is allowed
    const isForceAccess = 'force' in query

    let context: FormContext = {
      evaluationState: {},
      relevantState: {},
      relevantPages: [],
      payload: page.getFormDataFromState(request, state),
      state,
      paths: [],
      errors,
      isForceAccess,
      data: {},
      pageDefMap: this.pageDefMap,
      listDefMap: this.listDefMap,
      componentDefMap: this.componentDefMap,
      pageMap: this.pageMap,
      componentMap: this.componentMap,
      referenceNumber: getReferenceNumber(state)
    }

    // Validate current page
    context = validateFormPayload(request, page, context)

    // Find start page
    let nextPage = findPage(this, startPath)

    this.initialiseContext(context)

    // Walk form pages from start
    while (nextPage) {
      // Add page to context
      context.relevantPages.push(nextPage)

      this.assignEvaluationState(context, nextPage)

      this.assignRelevantState(context, nextPage)

      // Stop at current page
      if (
        this.pageStateIsInvalid(context, nextPage) ||
        nextPage.path === currentPath
      ) {
        break
      }

      // Apply conditions to determine next page
      nextPage = findPage(this, nextPage.getNextPath(context))
    }

    // Validate form state
    context = validateFormState(request, page, context)

    // Add paths for navigation
    this.assignPaths(context)

    return context
  }

  private initialiseContext(context: FormContext) {
    // Initialise `evaluationState` for all keys using empty state.
    // This is because the current condition evaluation library (eval-expr)
    // will throw if an expression uses a key that is undefined.
    const emptyState = Object.freeze({})

    for (const page of this.pages) {
      const { collection, pageDef } = page

      if (!hasRepeater(pageDef)) {
        Object.assign(
          context.evaluationState,
          collection.getContextValueFromState(emptyState)
        )
      }
    }
  }

  private assignEvaluationState(
    context: FormContext,
    page: PageControllerClass
  ) {
    const { collection, pageDef } = page
    // Skip evaluation state for repeater pages

    if (!hasRepeater(pageDef)) {
      Object.assign(
        context.evaluationState,
        collection.getContextValueFromState(context.state)
      )
    }
  }

  private assignRelevantState(context: FormContext, page: PageControllerClass) {
    // Copy relevant state by expected keys
    for (const key of page.keys) {
      if (typeof context.state[key] !== 'undefined') {
        context.relevantState[key] = context.state[key]
      }
    }
  }

  private pageStateIsInvalid(context: FormContext, page: PageControllerClass) {
    // Get any list-bound fields on the page
    const listFields = page.collection.fields.filter(hasListFormField)

    // For each list field that is bound to a list that contains any conditional items,
    // we need to check any answers are still valid. Do this by evaluating the conditions
    // and ensuring any current answers are all included in the set of valid answers
    for (const field of listFields) {
      const list = field.list

      // Filter out YesNo as they can't be conditional
      if (list !== undefined && field.type !== ComponentType.YesNoField) {
        const hasOptionalItems =
          list.items.filter((item) => item.condition).length > 0

        if (hasOptionalItems) {
          return this.fieldStateIsInvalid(context, field, list)
        }
      }
    }
  }

  private fieldStateIsInvalid(
    context: FormContext,
    field: ListFormComponent,
    list: List
  ) {
    const { evaluationState, state } = context

    const validValues = list.items
      .filter((item) =>
        item.condition
          ? this.conditions[item.condition]?.fn(evaluationState)
          : true
      )
      .map((item) => item.value)

    // Get the field state
    const fieldState = field.getFormValueFromState(state)

    if (fieldState !== undefined) {
      let isInvalid = false
      const isArray = Array.isArray(fieldState)

      // Check if any saved state value(s) are still valid
      // and return true if any are invalid
      if (isArray) {
        isInvalid = !fieldState.every((item) => validValues.includes(item))
      } else {
        isInvalid = !validValues.includes(fieldState)
      }

      if (isInvalid) {
        context.errors ??= []

        const text =
          'Options are different because you changed a previous answer'

        context.errors.push({
          text,
          name: field.name,
          href: `#${field.name}`,
          path: [`#${field.name}`]
        })
      }

      return isInvalid
    }
  }

  private assignPaths(context: FormContext) {
    for (const { keys, path } of context.relevantPages) {
      context.paths.push(path)

      // Stop at page with errors
      if (
        context.errors?.some(({ name, path }) => {
          return keys.includes(name) || keys.some((key) => path.includes(key))
        })
      ) {
        break
      }
    }
  }

  getComponentById(componentId: string): ComponentDef | undefined {
    return this.componentDefIdMap.get(componentId)
  }

  getListById(listId: string): List | undefined {
    return this.listDefIdMap.get(listId)
  }

  /**
   * Returns a condition by its ID. O(n) lookup time.
   * @param conditionId
   * @returns
   */
  getConditionById(conditionId: string): ConditionWrapperV2 | undefined {
    return this.def.conditions
      .filter(isConditionWrapperV2)
      .find((condition) => condition.id === conditionId)
  }

  /**
   * Checks that only one payment field exists (if any payments fields exist)
   */
  moreThanOnePaymentQuestion() {
    const numOfPaymentFields = this.def.pages
      .flatMap((page) =>
        hasComponentsEvenIfNoNext(page) ? page.components : []
      )
      .filter((comp) => comp.type === ComponentType.PaymentField).length
    return numOfPaymentFields > 1
  }
}

/**
 * Validate current page only
 */
function validateFormPayload(
  request: FormContextRequest,
  page: PageControllerClass,
  context: FormContext
): FormContext {
  const { collection } = page
  const { payload, state } = context

  const { action } = page.getFormParams(request)

  // Skip validation GET requests or other actions
  if (
    !request.payload ||
    (action &&
      action !== FormAction.Validate &&
      !action.startsWith(FormAction.External))
  ) {
    return context
  }

  // For checkbox fields missing in the payload (i.e. unchecked),
  // explicitly set their value to undefined so that any previously
  // stored value is cleared and required field validation is enforced.
  const update = { ...request.payload }
  collection.fields.forEach((field) => {
    if (
      field.type === ComponentType.CheckboxesField &&
      !(field.name in update)
    ) {
      update[field.name] = undefined
    }
  })

  const { value, errors } = collection.validate({
    ...payload,
    ...update
  })

  // Add sanitised payload (ready to save)
  const formState = page.getStateFromValidForm(request, state, value)

  return {
    ...context,
    payload: merge(payload, value),
    state: merge(state, formState),
    errors
  }
}

/**
 * Validate entire form state
 */
function validateFormState(
  request: FormContextRequest,
  page: PageControllerClass,
  context: FormContext
): FormContext {
  const { errors = [], relevantPages, relevantState } = context

  // Exclude current page
  const previousPages = relevantPages.filter(
    (relevantPage) => relevantPage !== page
  )

  // Validate relevant state
  const { error } = page.model
    .makeFilteredSchema(previousPages)
    .validate(relevantState, { ...opts, stripUnknown: true })

  // Add relevant state errors
  if (error) {
    const errorsState = error.details.map(getError)
    return { ...context, errors: errors.concat(errorsState) }
  }

  return context
}

function getReferenceNumber(state: FormSubmissionState): string {
  if (
    !state.$$__referenceNumber ||
    typeof state.$$__referenceNumber !== 'string'
  ) {
    throw Error('Reference number not found in form state')
  }

  return state.$$__referenceNumber
}
