Source: models/responseDetails.js

import { getVerboseColumns } from '../utils/getVerboseColumns.js'
import logger from '../utils/logger.js'
import { types } from '../utils/logging.js'
import { pagination } from '../utils/pagination.js'
import axios from 'axios'
import config from '../../config/index.js'

/**
 * @typedef {Object} PaginationOptions
 * @property {string} [hash] - Hash option (should include the '#' character)
 * @property {Function} [href] - Function to generate href (item: number) => string
 */

/**
 * @typedef {Object} PaginationItem
 * @property {string} href - Link URL
 * @property {boolean} [ellipsis] - Whether this is an ellipsis item
 */

/**
 * @typedef {Object} PaginationResult
 * @property {number} totalResults - Total number of results
 * @property {number} offset - Current offset
 * @property {number} limit - Results per page limit
 * @property {number} currentPage - Current page number
 * @property {number|null} nextPage - Next page number or null
 * @property {number|null} previousPage - Previous page number or null
 * @property {number} totalPages - Total number of pages
 * @property {Array<{href: string}>} items - Pagination items with hrefs
 */

/**
 * Holds response data of 'http://ASYNC-REQUEST-API-HOST/requests/:result-id/response-details' endpoint.
 */
export default class ResponseDetails {
  #cachedFields
  #cachedGeometries
  #hasFetchedGeometries = false

  constructor (id, response, pagination, columnFieldLog) {
    this.id = id
    this.response = response
    this.pagination = pagination
    this.columnFieldLog = columnFieldLog
  }

  getRows () {
    if (!this.response) {
      logger.warn('trying to get response details when there are none', {
        requestId: this.id
      })
      return []
    }
    return this.response
  }

  /**
   * @returns {Object[]}
   */
  getColumnFieldLog () {
    if (!this.columnFieldLog) {
      logger.warn('trying to get column field log when there is none', {
        requestId: this.id
      })
      return []
    }
    return this.columnFieldLog
  }

  /**
   * Returns a collection of field names, where each name is either a column name from input data
   * or field name from the column field log. The resulting collection includes fields that
   * the submitted data might be missing.
   *
   * Note: fields are `converted_row.column | columnFieldLog.field`
   *
   * @returns {string[]}
   */
  getFields () {
    if (this.#cachedFields) {
      return this.#cachedFields
    }

    const columnKeys = new Set()
    const rows = this.getRows()
    if (rows.length > 0) {
      const keys = Object.keys(rows[0].converted_row)
      for (const key of keys) {
        columnKeys.add(key)
      }
    }

    const columnFieldLog = this.getColumnFieldLog()
    for (const [col, field] of columnFieldLog.map((field) => [field.column, field.field])) {
      if (!columnKeys.has(col)) {
        columnKeys.add(field)
      }
    }

    this.#cachedFields = [...columnKeys]
    return this.#cachedFields
  }

  /**
   * Returns an array of rows with verbose columns, optionally filtering out rows without errors.
   *
   * @param {boolean} [filterNonErrors=false] - If true, only return rows that have at least one error.
   * @returns {Array<object>} An array of rows with verbose columns, each containing:
   *   - `entryNumber`: the entry number of the row
   *   - `hasErrors`: a boolean indicating whether the row has any errors
   *   - `columns`: an array of verbose column details, each containing:
   *     - `key`: the column key
   *     - `value`: the column value
   *     - `column`: the column name
   *     - `field`: the field name
   *     - `error`: an error message if data was missing
   */
  getRowsWithVerboseColumns (filterNonErrors = false) {
    if (!this.response) {
      logger.warn('trying to get response details when there are none', {
        requestId: this.id
      })
      return []
    }

    let rows = this.response

    if (filterNonErrors) {
      rows = rows.filter(
        (row) =>
          row.issue_logs.filter((issue) => issue.severity === 'error').length >
          0
      )
    }

    // Map over the details in the response and return an array of rows with verbose columns
    return rows.map((row) => ({
      entryNumber: row.entry_number,
      hasErrors:
        row.issue_logs.filter((issue) => issue.severity === 'error').length > 0,
      columns: getVerboseColumns(row, this.getColumnFieldLog())
    }))
  }

  getGeometryKey () {
    const columnFieldLog = this.getColumnFieldLog()

    if (!columnFieldLog) {
      return null
    }

    const columnFieldEntry =
      columnFieldLog.find((column) => column.field === 'point') ||
      columnFieldLog.find((column) => column.field === 'geometry')

    if (!columnFieldEntry) {
      return null
    }

    return columnFieldEntry.column
  }

  /**
   * Returns array of geometries when available, `undefined` otherwise.
   *
   * @returns {any[] | undefined }
   */
  async getGeometries () {
    if (this.#hasFetchedGeometries) {
      return this.#cachedGeometries
    }

    this.#cachedGeometries = await this.#fetchGeometries()
    this.#hasFetchedGeometries = true
    return this.#cachedGeometries
  }

  async #fetchGeometries () {
    if (!this.id) {
      return undefined
    }

    const url = new URL(`${config.asyncRequestApi.url}/${config.asyncRequestApi.requestsEndpoint}/${this.id}/geometries`)
    let response
    try {
      response = await axios.get(url, { timeout: 30000 })
    } catch (error) {
      logger.warn('failed to fetch response geometries', {
        type: types.DataFetch,
        requestId: this.id,
        errorMessage: error.message
      })
      return undefined
    }
    const totalResults = Number.parseInt(response.headers?.['x-pagination-total-results'])
    const geometries = response.data

    if (!Array.isArray(geometries)) {
      return undefined
    }

    const limit = Number.parseInt(response.headers?.['x-pagination-limit']) || geometries.length || 500

    if (!Number.isInteger(totalResults) || geometries.length >= totalResults) {
      return geometries.length > 0 ? geometries : undefined
    }

    for (let offset = geometries.length; offset < totalResults; offset += limit) {
      const pageUrl = new URL(url)
      pageUrl.searchParams.set('offset', offset)
      pageUrl.searchParams.set('limit', limit)
      const page = await axios.get(pageUrl, { timeout: 30000 })
      if (!Array.isArray(page.data)) {
        break
      }
      geometries.push(...page.data)
    }

    return geometries
  }

  /**
   * Get pagination details for the current response
   *
   * @param {number} pageNumber
   * @param {PaginationOptions} [opts] - Pagination options
   * @returns {PaginationResult} Pagination result object
   */
  getPagination (pageNumber, opts = {}) {
    pageNumber = parseInt(pageNumber)
    if (Number.isNaN(pageNumber)) pageNumber = 1
    const totalPages = Math.ceil(
      this.pagination.totalResults / this.pagination.limit
    )

    const hash = opts.hash ?? ''
    const hrefFn =
      opts.href ?? ((item) => `/check/results/${this.id}/${item}${hash}`)
    const items = pagination(totalPages, pageNumber).map((item) => {
      if (item === '...') {
        return {
          ellipsis: true,
          href: '#'
        }
      } else {
        return {
          number: item,
          href: hrefFn(item),
          current: pageNumber === item
        }
      }
    })

    return {
      totalResults: parseInt(this.pagination.totalResults),
      offset: parseInt(this.pagination.offset),
      limit: parseInt(this.pagination.limit),
      currentPage: pageNumber,
      nextPage: pageNumber < totalPages ? pageNumber + 1 : null,
      previousPage: pageNumber > 1 ? pageNumber - 1 : null,
      totalPages,
      items
    }
  }
}