/**
 * Copyright (c) Codice Foundation
 *
 * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
 * General Public License as published by the Free Software Foundation, either version 3 of the
 * License, or any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details. A copy of the GNU Lesser General Public License
 * is distributed along with this program and can be found at
 * <http://www.gnu.org/licenses/lgpl.html>.
 *
 **/
import moment from 'moment-timezone'
import { ValuesType } from 'utility-types'
import CQLUtils from '../../js/CQLUtils'
import { Omit } from '../../typescript'

import { SpreadOperatorProtectedClass } from '../../typescript/classes'
import ExtensionPoints from '../../extension-points'
import { BasicDataTypePropertyName } from './reserved.properties'

export const deserialize = {
  /**
   * example inputs:  // ' are part of input
   * 'RELATIVE(PT1S)' // last 1 seconds
   * 'RELATIVE(PT1M)' // last 1 minutes
   * 'RELATIVE(PT1H)' // last 1 hours
   * 'RELATIVE(P1D)' // last 1 days
   * 'RELATIVE(P7D)' // last 1 weeks (notice we get mod of 7 days)
   * 'RELATIVE(P1M)' // last 1 month
   * 'RELATIVE(P1Y)' // last 1 year
   **/
  dateRelative: (val: string): ValueTypes['relative'] => {
    let last = ''
    const guts = val.split('(')[1].split(')')[0].substring(1) // get the thing between the parens, notice we don't care about P either
    let unit = guts.charAt(guts.length - 1) // notice that we still need to know if it's minutes or months, the unit is the same between them!
    if (guts.charAt(0) === 'T') {
      last = guts.substring(1, guts.length - 1)
      unit = unit.toLowerCase()
    } else {
      last = guts.substring(0, guts.length - 1)
      if (unit !== 'M') {
        unit = unit.toLowerCase() // only M differs given the conflict between minutes / months
      }
      if (unit === 'd') {
        if (Number(last) % 7 === 0) {
          // manually convert to weeks since it's not "really" a cql supported unit for relative
          last = (Number(last) / 7).toString()
          unit = 'w'
        }
      }
    }
    return {
      last,
      unit,
    } as ValueTypes['relative']
  },
}

export const serialize = {
  dateRelative: ({ last, unit }: ValueTypes['relative']) => {
    if (unit === undefined || !parseFloat(last)) {
      return ''
    }
    //Weeks is not a valid unit, so convert this to days
    if (unit === 'w') {
      let convertedUnit = 'd'
      let convertedLast = (parseFloat(last) * 7).toString()
      return `RELATIVE(${'P' + convertedLast + convertedUnit.toUpperCase()})`
    }
    const prefix = unit === 's' || unit === 'm' || unit === 'h' ? 'PT' : 'P'
    return `RELATIVE(${prefix + last + unit.toUpperCase()})`
  },
  dateAround: (value: ValueTypes['around']) => {
    if (
      value.buffer === undefined ||
      value.date === undefined ||
      value.direction === undefined
    ) {
      return ''
    }
    let before = ['both', 'before'].includes(value.direction)
      ? moment(value.date)
          .subtract(value.buffer.amount, value.buffer.unit)
          .toISOString()
      : value.date
    let after = ['both', 'after'].includes(value.direction)
      ? moment(value.date)
          .add(value.buffer.amount, value.buffer.unit)
          .toISOString()
      : value.date
    return `DURING ${before}/${after}`
  },
  dateBetween: (value: ValueTypes['between']) => {
    const from = moment(value.start)
    const to = moment(value.end)
    if (from.isAfter(to)) {
      return (to.toISOString() || '') + '/' + (from.toISOString() || '')
    }
    return (from.toISOString() || '') + '/' + (to.toISOString() || '')
  },
  location: (property: string, value: ValueTypes['location']) => {
    const transformation = ExtensionPoints.serializeLocation(property, value)
    if (transformation !== null) {
      return transformation
    }
    return CQLUtils.generateAnyGeoFilter(property, value)
  },
}

class BaseFilterBuilderClass extends SpreadOperatorProtectedClass {
  readonly type: string
  readonly filters: Array<any>
  readonly negated: boolean
  readonly id: string
  constructor({
    type = 'AND',
    filters = [new FilterClass()],
    negated = false,
    id = Math.random().toString(),
  }: {
    type?: BaseFilterBuilderClass['type']
    filters?: BaseFilterBuilderClass['filters']
    negated?: BaseFilterBuilderClass['negated']
    id?: string
  } = {}) {
    super()
    this.type = type
    /**
     * If for some reason filters come in that aren't classed, this will handle it.
     */
    this.filters = filters.map((childFilter) => {
      if (
        isFilterBuilderClass(childFilter) ||
        shouldBeFilterBuilderClass(childFilter)
      ) {
        return new FilterBuilderClass({
          ...childFilter,
        })
      } else {
        return new FilterClass({
          ...childFilter,
        })
      }
    })
    this.negated = negated
    this.id = id
  }
}
export class FilterBuilderClass extends BaseFilterBuilderClass {
  declare readonly type: 'AND' | 'OR'
  declare readonly filters: Array<FilterBuilderClass | FilterClass>
  constructor({
    type = 'AND',
    filters = [new FilterClass()],
    negated = false,
    id = Math.random().toString(),
  }: {
    type?: FilterBuilderClass['type']
    filters?: FilterBuilderClass['filters']
    negated?: FilterBuilderClass['negated']
    id?: string
  } = {}) {
    super({ type, filters, negated, id })
  }
}

/**
 *  We want to support more complex negation than allowed by the normal filter tree, so we store negated in a property.
 *  However, since the write function in cql.tsx doesn't know about this, at some point we need to convert it back to this class where negation exists as a type instead of property.
 *  See the uncollapseNots function in cql.tsx for where this is done.
 */
export class CQLStandardFilterBuilderClass extends BaseFilterBuilderClass {
  declare readonly type: 'AND' | 'OR' | 'NOT'
  declare readonly filters: Array<
    FilterClass | CQLStandardFilterBuilderClass | FilterBuilderClass
  >
  constructor({
    type = 'AND',
    filters = [new FilterClass()],
    negated = false,
    id = Math.random().toString(),
  }: {
    type?: CQLStandardFilterBuilderClass['type']
    filters?: CQLStandardFilterBuilderClass['filters']
    negated?: CQLStandardFilterBuilderClass['negated']
    id?: string
  } = {}) {
    super({ type, filters, negated, id })
  }
}

export type BooleanTextType = {
  text: string
  cql: string
  error: boolean
  errorMessage?: string
}

export type ValueTypes = {
  proximity: {
    first: string
    second: string
    distance: number
  }
  date: string
  boolean: boolean
  text: string
  float: number
  integer: number
  relative: {
    last: string
    //NOTE: Weeks is not a valid unit, but we allow it in our system.
    //This is converted to days to become valid cql
    unit: 'm' | 'h' | 'd' | 'M' | 'y' | 's' | 'w'
  }
  around: {
    date: string
    buffer: {
      amount: string
      unit: 'm' | 'h' | 'd' | 'M' | 'y' | 's' | 'w'
    }
    direction: 'both' | 'before' | 'after'
  }
  during: {
    start: string
    end: string
  }
  between: {
    start: number
    end: number
  }
  multivalue: string[]
  booleanText: BooleanTextType
  location: // this is all we technically need to reconstruct (lo fidelity)
  LineLocation | PolygonLocation | PointRadiusLocation
}

export type LineLocation = {
  type: 'LINE'
  mode: 'line'
  lineWidth?: string
  line: Array<Array<number>>
}
export type PolygonLocation = {
  type: 'POLYGON'
  polygonBufferWidth?: number | string
  polygonBufferUnits?: 'meters'
  polygon: Array<Array<number>>
  locationType?: 'dd'
  polyType?: 'polygon'
  mode: 'poly'
}
export type PointRadiusLocation = {
  type: 'POINTRADIUS'
  radius: number | string
  radiusUnits?: 'meters'
  mode: 'circle'
  lat: number
  lon: number
  locationType?: 'dd'
}

export type ValueType = string | boolean | null | ValuesType<ValueTypes>

export type FilterContext = {
  [key: string]: any
}

export class FilterClass extends SpreadOperatorProtectedClass {
  type:
    | 'BEFORE'
    | 'AFTER'
    | 'RELATIVE'
    | '='
    | 'DURING'
    | 'GEOMETRY'
    | 'DWITHIN'
    | 'ILIKE'
    | 'LIKE'
    | 'IS NULL'
    | '>'
    | '<'
    | '='
    | '<='
    | '>='
    | 'DURING'
    | 'BETWEEN'
    | 'FILTER FUNCTION proximity'
    | 'AROUND' // This isn't valid cql, but something we support
    | 'BOOLEAN_TEXT_SEARCH' // This isn't valid cql, but something we support
  readonly property: string
  readonly value: ValueType
  readonly negated: boolean | undefined
  readonly id: string
  readonly context?: FilterContext
  constructor({
    type = 'ILIKE',
    property = 'anyText',
    value = '',
    negated = false,
    id = Math.random().toString(),
    context,
  }: {
    type?: FilterClass['type']
    property?: FilterClass['property']
    value?: FilterClass['value']
    negated?: FilterClass['negated']
    id?: string
    context?: FilterContext
  } = {}) {
    super()
    this.type = type
    this.property = property
    this.value = value
    this.negated = negated
    this.id = id
    this.context = context
  }
}

/**
 * determine it is actually an plain object form of the filter builder class
 */
export const shouldBeFilterBuilderClass = (
  filter: any
): filter is FilterBuilderClass => {
  return !isFilterBuilderClass(filter) && filter.filters !== undefined
}

/**
 *determine it is actually an instantiation of the filter builder class
 */
export const isFilterBuilderClass = (
  filter:
    | FilterBuilderClass
    | FilterClass
    | CQLStandardFilterBuilderClass
    | Partial<FilterBuilderClass>
    | Partial<FilterClass>
    | Partial<CQLStandardFilterBuilderClass>
): filter is FilterBuilderClass => {
  return filter.constructor === FilterBuilderClass
}

/**
 *determine it is actually an instantiation of the cql standard filter builder class
 */
export const isCQLStandardFilterBuilderClass = (
  filter:
    | FilterBuilderClass
    | FilterClass
    | CQLStandardFilterBuilderClass
    | Partial<FilterBuilderClass>
    | Partial<FilterClass>
    | Partial<CQLStandardFilterBuilderClass>
): filter is CQLStandardFilterBuilderClass => {
  return filter.constructor === CQLStandardFilterBuilderClass
}

export interface BasicFilterClass extends Omit<FilterClass, 'property'> {
  property: string[]
}

interface BasicDatatypeClass extends Omit<FilterClass, 'property' | 'value'> {
  property: typeof BasicDataTypePropertyName
  value: string[]
}

export class BasicDatatypeFilter
  extends FilterClass
  implements BasicDatatypeClass
{
  property: typeof BasicDataTypePropertyName
  value: string[]

  constructor({
    value = [],
    context,
  }: {
    value?: string[]
    context?: FilterContext
  } = {}) {
    super({
      property: BasicDataTypePropertyName,
      value,
      context,
    })
    this.value = value
  }
}

export const isBasicDatatypeClass = (
  filter:
    | FilterBuilderClass
    | FilterClass
    | CQLStandardFilterBuilderClass
    | Partial<FilterBuilderClass>
    | Partial<FilterClass>
    | Partial<CQLStandardFilterBuilderClass>
): filter is BasicDatatypeClass => {
  try {
    return (filter as any)?.property === BasicDataTypePropertyName
  } catch (err) {
    return false
  }
}

export class ResourceSizeFilterClass extends FilterClass {
  declare value: number
  declare context: FilterContext & { units: string }
  constructor({
    value = 0,
    context = { units: 'B' },
    ...otherProps
  }: Omit<FilterClass, '_DO_NOT_USE_SPREAD_OPERATOR' | 'value' | 'context'> & {
    value: number
    context: FilterContext & { units: string }
  }) {
    super({
      ...otherProps,
      value,
      context: {
        ...context,
        units: context.units,
      },
    })
  }
}

export class ResourceSizeRangeFilterClass extends FilterClass {
  declare value: { start: number; end: number }
  declare context: FilterContext & { startUnits: string; endUnits: string }
  constructor({
    value = { start: 0, end: 1 },
    context = { startUnits: 'B', endUnits: 'B' },
    ...otherProps
  }: Omit<FilterClass, '_DO_NOT_USE_SPREAD_OPERATOR' | 'value' | 'context'> & {
    value: { start: number; end: number }
    context: FilterContext & { startUnits: string; endUnits: string }
  }) {
    super({
      ...otherProps,
      value,
      context: {
        ...context,
        startUnits: context.startUnits,
        endUnits: context.endUnits,
      },
    })
  }
}

export const isResourceSizeFilterClass = (
  filter:
    | FilterBuilderClass
    | FilterClass
    | CQLStandardFilterBuilderClass
    | Partial<FilterBuilderClass>
    | Partial<FilterClass>
    | Partial<CQLStandardFilterBuilderClass>
): filter is ResourceSizeFilterClass => {
  try {
    return (
      (filter as any)?.value !== undefined &&
      typeof (filter as any)?.value === 'number' &&
      !isNaN((filter as any)?.value) &&
      (filter as any)?.context?.units !== undefined &&
      sizeUnits.includes((filter as any)?.context?.units)
    )
  } catch (err) {
    return false
  }
}

export const isResourceSizeRangeFilterClass = (
  filter:
    | FilterBuilderClass
    | FilterClass
    | CQLStandardFilterBuilderClass
    | Partial<FilterBuilderClass>
    | Partial<FilterClass>
    | Partial<CQLStandardFilterBuilderClass>
): filter is ResourceSizeRangeFilterClass => {
  try {
    return (
      (filter as any)?.value !== undefined &&
      typeof (filter as any)?.value === 'object' &&
      typeof (filter as any)?.value.start === 'number' &&
      typeof (filter as any)?.value.end === 'number' &&
      (filter as any)?.context?.startUnits !== undefined &&
      (filter as any)?.context?.endUnits !== undefined &&
      sizeUnits.includes((filter as any)?.context?.startUnits) &&
      sizeUnits.includes((filter as any)?.context?.endUnits)
    )
  } catch (err) {
    return false
  }
}

export const sizeUnits = [
  'B', // Bytes
  'KB', // Kilobytes
  'MB', // Megabytes
  'GB', // Gigabytes
  'TB', // Terabytes
  'PB', // Petabytes
]

const convertToBytesFromUnit = (value: number, unit: string): number => {
  const unitIndex = sizeUnits.indexOf(unit)
  if (unitIndex === -1) {
    return value // If unit is not recognized, return the original value
  }
  return value * Math.pow(1000, unitIndex)
}

// This function rounds down to the nearest whole number in bytes
export const convertResourceSizeFilterClassValueToBytes = (
  filter: ResourceSizeFilterClass
): number => {
  const { value, context } = filter
  const unit = context?.units || 'B'
  return Math.floor(convertToBytesFromUnit(value, unit))
}

export const convertResourceSizeRangeFilterClassValueToBytes = (
  filter: ResourceSizeRangeFilterClass
): { start: number; end: number } => {
  const { value, context } = filter
  const startUnit = context?.startUnits || 'B'
  const endUnit = context?.endUnits || 'B'
  return {
    start: Math.floor(convertToBytesFromUnit(value.start, startUnit)),
    end: Math.floor(convertToBytesFromUnit(value.end, endUnit)),
  }
}

export function convertBytesToHumanReadable(bytes: number): string {
  const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
  let unitIndex = 0
  while (bytes >= 1000 && unitIndex < units.length - 1) {
    bytes /= 1000
    unitIndex++
  }
  return `${bytes.toFixed(2)} ${units[unitIndex]}`
}
