import JSZip from 'jszip'
import Papa, { ParseResult, ParseConfig } from 'papaparse'

type CsvPreviewValidator<T> = (value: Required<T>[keyof T], row: T) => boolean

interface CsvPreviewOptions<T> {
  // Поля, отсутсвие которых в заголовке приведет к исключению
  requiredFields?: (keyof T)[]
  // Как много строк нужно распарсить в файле/строке
  rowsLimit?: number
  // Валидаторы столбцов
  validators?: { [F in keyof T]?: CsvPreviewValidator<T> }
}

export default class CsvPreviewService<T> {
  private readonly options: CsvPreviewOptions<T>

  constructor(options: CsvPreviewOptions<T> = {}) {
    this.options = options
  }

  async execute(
    file: File,
    options?: CsvPreviewOptions<T>
  ): Promise<CsvPreviewResult<T>> {
    const worker = new CsvPreviewWorker<T>(
      Object.assign({}, this.options, options)
    )

    return worker.execute(file)
  }
}

class CsvPreviewWorker<T> {
  private readonly options: CsvPreviewOptions<T>

  constructor(options: CsvPreviewOptions<T> = {}) {
    this.options = options
  }

  public async execute(file: File): Promise<CsvPreviewResult<T>> {
    const unzipped = await this.unzip(file)

    const result = await this.parse(unzipped)

    this.validateFields(result)

    const { rows, errors } = this.validateRows(result)

    const { delimiter } = result.meta

    return new CsvPreviewResult<T>(rows, delimiter, errors)
  }

  private async unzip(file: File): Promise<File | string> {
    if (file.type !== 'application/zip') {
      return file
    }

    let zip = null
    try {
      zip = await JSZip.loadAsync(file)
    } catch (e) {
      throw new Error('Файл не является ZIP-архивом или поврежден')
    }

    const zipFile = Object.values(zip.files)?.[0]

    if (!zipFile) {
      throw new Error('Архив пуст')
    }

    return await zipFile.async('string')
  }

  private parse(file: File | string) {
    const config: ParseConfig = {
      header: true,
      preview: this.options.rowsLimit ?? 0,
      worker: true,
      skipEmptyLines: true,
    }

    return new Promise<ParseResult<T>>((resolve, reject) => {
      Papa.parse<T>(file, {
        ...config,
        complete(results) {
          resolve(results)
        },
        error(error) {
          reject(error)
        },
      })
    })
  }

  private validateFields(result: ParseResult<T>) {
    const requiredFields = this.options.requiredFields

    if (requiredFields) {
      const fields = result.meta.fields

      if (!fields) {
        throw new Error('Не распарсились заголовки')
      }

      const missedFields = requiredFields.filter(
        required => !fields.includes(required as string)
      )

      if (missedFields.length) {
        throw new Error(
          `Отсутствуют обязательные поля ${missedFields.join(', ')}`
        )
      }
    }
  }

  private validateRows(
    result: ParseResult<T>
  ): { rows: T[]; errors: number[] } {
    const { validators } = this.options

    const { data } = result

    const errors: number[] = []

    if (!validators) {
      return { rows: data, errors }
    }

    for (let i = 0, len = data.length; i < len; i++) {
      const row = data[i]

      for (const [field, func] of Object.entries(validators) as [
        keyof T,
        CsvPreviewValidator<T>
      ][]) {
        const val = row[field]

        if (!func(val, row)) {
          errors.push(i)

          break
        }
      }
    }

    return { rows: data, errors }
  }
}

class CsvPreviewResult<T> {
  rows: T[]
  delimiter: string
  errors: number[]

  constructor(rows: T[], delimiter: string, errors: number[]) {
    this.rows = rows
    this.delimiter = delimiter
    this.errors = errors
  }

  takeValidRows(count: number): T[] {
    const { rows, errors } = this

    // Валидные строки

    const slices: T[][] = []

    let needed = Math.min(count, rows.length - errors.length)
    let cursor = 0

    for (const error of errors) {
      if (error > cursor) {
        const slice = rows.slice(cursor, error).slice(0, needed)

        slices.push(slice)

        needed -= slice.length

        if (needed === 0) {
          cursor = error + 1

          break
        }
      }

      cursor = error + 1
    }

    if (needed) {
      slices.push(rows.slice(cursor, cursor + needed))
    }

    return ([] as T[]).concat(...slices)
  }

  takeInvalidRows(count: number): T[] {
    const { rows, errors } = this

    const result: T[] = []
    for (const error of errors.slice(0, count)) {
      result.push(rows[error])
    }

    return result
  }
}
