// @ts-check
import apexchartsLegendCSS from '../assets/apexcharts-legend.css'
import AxesUtils from '../modules/axes/AxesUtils'
import Data from '../modules/Data'
import Series from '../modules/Series'
import Utils from '../utils/Utils'
import { Environment } from '../utils/Environment.js'

class Exports {
  /**
   * @param {import('../types/internal').ChartStateW} w
   * @param {import('../types/internal').ChartContext} ctx
   */
  constructor(w, ctx) {
    this.w = w
    this.ctx = ctx // needed: theme, timeScale (for AxesUtils), passes ctx to Data/Series
  }

  /**
   * @param {string} svgString
   */
  svgStringToNode(svgString) {
    const parser = new DOMParser()
    const svgDoc = parser.parseFromString(svgString, 'image/svg+xml')
    return svgDoc.documentElement
  }

  /**
   * @param {any} svg
   * @param {number} scale
   */
  scaleSvgNode(svg, scale) {
    // get current both width and height of the svg
    const svgWidth = parseFloat(svg.getAttributeNS(null, 'width'))
    const svgHeight = parseFloat(svg.getAttributeNS(null, 'height'))
    // set new width and height based on the scale
    svg.setAttributeNS(null, 'width', svgWidth * scale)
    svg.setAttributeNS(null, 'height', svgHeight * scale)
    svg.setAttributeNS(null, 'viewBox', '0 0 ' + svgWidth + ' ' + svgHeight)
  }

  /**
   * @param {number} [_scale]
   */
  getSvgString(_scale) {
    return new Promise((resolve) => {
      const w = this.w
      let scale =
        _scale ||
        w.config.chart.toolbar.export.scale ||
        w.config.chart.toolbar.export.width / w.globals.svgWidth

      if (!scale) {
        scale = 1 // if no scale is specified, don't scale...
      }

      const width = w.globals.svgWidth * scale
      const height = w.globals.svgHeight * scale

      const clonedNode = /** @type {HTMLElement} */ (
        w.dom.elWrap.cloneNode(true)
      )
      clonedNode.style.width = width + 'px'
      clonedNode.style.height = height + 'px'
      const serializedNode = new XMLSerializer().serializeToString(clonedNode)

      // Check if legend is shown and should be included in export
      const shouldIncludeLegendStyles =
        w.config.legend.show &&
        w.dom.elLegendWrap &&
        w.dom.elLegendWrap.children.length > 0

      // Base styles for export
      let exportStyles = `
        .apexcharts-tooltip, .apexcharts-toolbar, .apexcharts-xaxistooltip, .apexcharts-yaxistooltip, .apexcharts-xcrosshairs, .apexcharts-ycrosshairs, .apexcharts-zoom-rect, .apexcharts-selection-rect {
          display: none;
        }
      `

      // Add legend styles if legend is shown
      if (shouldIncludeLegendStyles) {
        exportStyles += apexchartsLegendCSS
      }

      let svgString = `
        <svg xmlns="http://www.w3.org/2000/svg"
          version="1.1"
          xmlns:xlink="http://www.w3.org/1999/xlink"
          class="apexcharts-svg"
          xmlns:data="ApexChartsNS"
          transform="translate(0, 0)"
          width="${w.globals.svgWidth}px" height="${w.globals.svgHeight}px">
          <foreignObject width="100%" height="100%">
            <div xmlns="http://www.w3.org/1999/xhtml" style="width:${width}px; height:${height}px;">
            <style type="text/css">
              ${exportStyles}
            </style>
              ${serializedNode}
            </div>
          </foreignObject>
        </svg>
      `

      const svgNode = this.svgStringToNode(svgString)

      if (scale !== 1) {
        // scale the image
        this.scaleSvgNode(svgNode, scale)
      }

      this.convertImagesToBase64(svgNode).then(() => {
        svgString = new XMLSerializer().serializeToString(svgNode)
        resolve(svgString.replace(/&nbsp;/g, '&#160;'))
      })
    })
  }

  /**
   * @param {any} svgNode
   */
  convertImagesToBase64(svgNode) {
    const images = svgNode.getElementsByTagName('image')
    const promises = Array.from(images).map((img) => {
      const href = img.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
      if (href && !href.startsWith('data:')) {
        return this.getBase64FromUrl(href)
          .then((base64) => {
            img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', base64)
          })
          .catch((error) => {
            console.error('Error converting image to base64:', error)
          })
      }
      return Promise.resolve()
    })
    return Promise.all(promises)
  }

  /**
   * @param {string} url
   */
  getBase64FromUrl(url) {
    if (Environment.isSSR()) return Promise.resolve(url)

    return new Promise((resolve, reject) => {
      const img = new Image()
      img.crossOrigin = 'Anonymous'
      img.onload = () => {
        const canvas = document.createElement('canvas')
        canvas.width = img.width
        canvas.height = img.height
        const ctx = canvas.getContext('2d')
        if (ctx) ctx.drawImage(img, 0, 0)
        resolve(canvas.toDataURL())
      }
      img.onerror = reject
      img.src = url
    })
  }

  svgUrl() {
    return new Promise((resolve) => {
      this.getSvgString().then((svgData) => {
        const svgBlob = new Blob([svgData], {
          type: 'image/svg+xml;charset=utf-8',
        })
        resolve(URL.createObjectURL(svgBlob))
      })
    })
  }

  /**
   * @param {Record<string, any> | undefined} options
   */
  dataURI(options) {
    if (Environment.isSSR()) return Promise.resolve({ imgURI: '' })

    return new Promise((resolve) => {
      const w = this.w

      const scale = options
        ? options.scale || options.width / w.globals.svgWidth
        : 1

      const canvas = document.createElement('canvas')
      canvas.width = w.globals.svgWidth * scale
      canvas.height = parseInt(w.dom.elWrap.style.height, 10) * scale // because of resizeNonAxisCharts

      const canvasBg =
        w.config.chart.background === 'transparent' ||
        !w.config.chart.background
          ? '#fff'
          : w.config.chart.background

      const ctx = canvas.getContext('2d')
      if (!ctx) return
      ctx.fillStyle = canvasBg
      ctx.fillRect(0, 0, canvas.width * scale, canvas.height * scale)

      this.getSvgString(scale).then((svgData) => {
        const svgUrl = 'data:image/svg+xml,' + encodeURIComponent(svgData)
        const img = new Image()
        img.crossOrigin = 'anonymous'

        img.onload = () => {
          ctx.drawImage(img, 0, 0)

          /** @type {any} */ const edgeCanvas = canvas
          if (edgeCanvas.msToBlob) {
            // Legacy Microsoft Edge can't navigate to data urls, so return the blob instead
            const blob = edgeCanvas.msToBlob()
            resolve({ blob })
          } else {
            const imgURI = canvas.toDataURL('image/png')
            resolve({ imgURI })
          }
        }

        img.src = svgUrl
      })
    })
  }

  exportToSVG() {
    this.svgUrl().then((url) => {
      this.triggerDownload(
        url,
        this.w.config.chart.toolbar.export.svg.filename,
        '.svg',
      )
    })
  }

  exportToPng() {
    const scale = this.w.config.chart.toolbar.export.scale
    const width = this.w.config.chart.toolbar.export.width
    const option = scale
      ? { scale: scale }
      : width
        ? { width: width }
        : undefined
    this.dataURI(option).then(({ imgURI, blob }) => {
      if (blob) {
        // @ts-ignore — msSaveOrOpenBlob is an IE11-only API
        navigator.msSaveOrOpenBlob(blob, this.w.globals.chartID + '.png')
      } else {
        this.triggerDownload(
          imgURI,
          this.w.config.chart.toolbar.export.png.filename,
          '.png',
        )
      }
    })
  }

  /** @param {{ series?: any, fileName?: any, columnDelimiter?: string, lineDelimiter?: string }} opts */
  exportToCSV({
    series,
    fileName,
    columnDelimiter = ',',
    lineDelimiter = '\n',
  }) {
    const w = this.w

    if (!series) series = w.config.series

    /** @type {any[]} */
    let columns = []
    const rows = []
    let result = ''
    const universalBOM = '\uFEFF'
    /**
     * @param {Record<string, any>} s
     * @param {number} i
     */
    const gSeries = w.seriesData.series.map((s, i) => {
      return w.globals.collapsedSeriesIndices.indexOf(i) === -1 ? s : []
    })

    /**
     * @param {any} cat
     */
    const getFormattedCategory = (cat) => {
      if (
        typeof w.config.chart.toolbar.export.csv.categoryFormatter ===
        'function'
      ) {
        return w.config.chart.toolbar.export.csv.categoryFormatter(cat)
      }

      if (w.config.xaxis.type === 'datetime' && String(cat).length >= 10) {
        return new Date(cat).toDateString()
      }
      return Utils.isNumber(cat) ? cat : cat.split(columnDelimiter).join('')
    }

    /**
     * @param {any} value
     */
    const getFormattedValue = (value) => {
      return typeof w.config.chart.toolbar.export.csv.valueFormatter ===
        'function'
        ? w.config.chart.toolbar.export.csv.valueFormatter(value)
        : value
    }

    const seriesMaxDataLength = Math.max(
      /**
       * @param {Record<string, any>} s
       */
      ...series.map((/** @type {any} */ s) => {
        return s.data ? s.data.length : 0
      }),
    )
    const dataFormat = new Data(this.w)

    const axesUtils = new AxesUtils(this.w, {
      theme: this.ctx.theme,
      timeScale: this.ctx.timeScale,
    })
    /**
     * @param {number} i
     */
    const getCat = (i) => {
      let cat = ''

      // pie / donut/ radial
      if (!w.globals.axisCharts) {
        cat = w.config.labels[i]
      } else {
        // xy charts

        // non datetime
        if (
          w.config.xaxis.type === 'category' ||
          w.config.xaxis.convertedCatToNumeric
        ) {
          if (w.globals.isBarHorizontal) {
            const lbFormatter = w.formatters.yLabelFormatters[0]
            const sr = new Series(this.ctx.w)
            const activeSeries = sr.getActiveConfigSeriesIndex()

            cat = lbFormatter(w.labelData.labels[i], {
              seriesIndex: activeSeries,
              dataPointIndex: i,
              w,
            })
          } else {
            cat = axesUtils.getLabel(
              w.labelData.labels,
              w.labelData.timescaleLabels,
              0,
              i,
            ).text
          }
        }

        // datetime, but labels specified in categories or labels
        if (w.config.xaxis.type === 'datetime') {
          if (w.config.xaxis.categories.length) {
            cat = w.config.xaxis.categories[i]
          } else if (w.config.labels.length) {
            cat = w.config.labels[i]
          }
        }
      }

      // let the caller know the current category is null. this can happen for example
      // when dealing with line charts having inconsistent time series data
      if (cat === null) return 'nullvalue'

      if (Array.isArray(cat)) {
        cat = cat.join(' ')
      }

      return Utils.isNumber(cat) ? cat : cat.split(columnDelimiter).join('')
    }

    // Fix https://github.com/apexcharts/apexcharts.js/issues/3365
    const getEmptyDataForCsvColumn = () => {
      return [...Array(seriesMaxDataLength)].map(() => '')
    }

    /**
     * @param {Record<string, any>} s
     * @param {number} sI
     */
    const handleAxisRowsColumns = (s, sI) => {
      if (columns.length && sI === 0) {
        // It's the first series.  Go ahead and create the first row with header information.
        rows.push(columns.join(columnDelimiter))
      }

      if (s.data) {
        // Use the data we have, or generate a properly sized empty array with empty data if some data is missing.
        s.data = (s.data.length && s.data) || getEmptyDataForCsvColumn()
        for (let i = 0; i < s.data.length; i++) {
          // Reset the columns array so that we can start building columns for this row.
          columns = []

          let cat = getCat(i)

          // current category is null, let's move on to the next one
          if (cat === 'nullvalue') continue

          if (!cat) {
            if (dataFormat.isFormatXY()) {
              cat = series[sI].data[i].x
            } else if (dataFormat.isFormat2DArray()) {
              cat = series[sI].data[i] ? series[sI].data[i][0] : ''
            }
          }

          if (sI === 0) {
            // It's the first series.  Also handle the category.
            columns.push(getFormattedCategory(cat))

            for (let ci = 0; ci < w.seriesData.series.length; ci++) {
              const value = dataFormat.isFormatXY()
                ? series[ci].data[i]?.y
                : gSeries[ci][i]
              columns.push(getFormattedValue(value))
            }
          }

          if (
            w.config.chart.type === 'candlestick' ||
            (s.type && s.type === 'candlestick')
          ) {
            columns.pop()
            columns.push(w.candleData.seriesCandleO[sI][i])
            columns.push(w.candleData.seriesCandleH[sI][i])
            columns.push(w.candleData.seriesCandleL[sI][i])
            columns.push(w.candleData.seriesCandleC[sI][i])
          }

          if (
            w.config.chart.type === 'boxPlot' ||
            (s.type && s.type === 'boxPlot')
          ) {
            columns.pop()
            columns.push(w.candleData.seriesCandleO[sI][i])
            columns.push(w.candleData.seriesCandleH[sI][i])
            columns.push(w.candleData.seriesCandleM[sI][i])
            columns.push(w.candleData.seriesCandleL[sI][i])
            columns.push(w.candleData.seriesCandleC[sI][i])
          }

          if (w.config.chart.type === 'rangeBar') {
            columns.pop()
            columns.push(w.rangeData.seriesRangeStart[sI][i])
            columns.push(w.rangeData.seriesRangeEnd[sI][i])
          }

          if (columns.length) {
            rows.push(columns.join(columnDelimiter))
          }
        }
      }
    }

    const handleUnequalXValues = () => {
      const categories = new Set()
      const data = {}

      /**
       * @param {Record<string, any>} s
       * @param {number} sI
       */
      series.forEach((/** @type {any} */ s, /** @type {any} */ sI) => {
        /**
         * @param {Record<string, any>} dataItem
         */
        s?.data.forEach((/** @type {any} */ dataItem) => {
          let cat, value
          if (dataFormat.isFormatXY()) {
            cat = dataItem.x
            value = dataItem.y
          } else if (dataFormat.isFormat2DArray()) {
            cat = dataItem[0]
            value = dataItem[1]
          } else {
            return
          }
          if (!(/** @type {Record<string,any>} */ (data)[cat])) {
            ;/** @type {Record<string,any>} */ (data)[cat] = Array(
              series.length,
            ).fill('')
          }
          ;/** @type {Record<string,any>} */ (data)[cat][sI] =
            getFormattedValue(value)
          categories.add(cat)
        })
      })

      if (columns.length) {
        rows.push(columns.join(columnDelimiter))
      }

      Array.from(categories)
        .sort()
        .forEach((cat) => {
          rows.push([
            getFormattedCategory(cat),
            /** @type {Record<string,any>} */ (data)[cat].join(columnDelimiter),
          ])
        })
    }

    columns.push(w.config.chart.toolbar.export.csv.headerCategory)

    if (w.config.chart.type === 'boxPlot') {
      columns.push('minimum')
      columns.push('q1')
      columns.push('median')
      columns.push('q3')
      columns.push('maximum')
    } else if (w.config.chart.type === 'candlestick') {
      columns.push('open')
      columns.push('high')
      columns.push('low')
      columns.push('close')
    } else if (w.config.chart.type === 'rangeBar') {
      columns.push('minimum')
      columns.push('maximum')
    } else {
      /**
       * @param {Record<string, any>} s
       * @param {number} sI
       */
      series.map((/** @type {any} */ s, /** @type {any} */ sI) => {
        const sname = (s.name ? s.name : `series-${sI}`) + ''
        if (w.globals.axisCharts) {
          columns.push(
            sname.split(columnDelimiter).join('')
              ? sname.split(columnDelimiter).join('')
              : `series-${sI}`,
          )
        }
      })
    }

    if (!w.globals.axisCharts) {
      columns.push(w.config.chart.toolbar.export.csv.headerValue)
      rows.push(columns.join(columnDelimiter))
    }

    if (
      !w.globals.allSeriesHasEqualX &&
      w.globals.axisCharts &&
      !w.config.xaxis.categories.length &&
      !w.config.labels.length
    ) {
      handleUnequalXValues()
    } else {
      /**
       * @param {Record<string, any>} s
       * @param {number} sI
       */
      series.map((/** @type {any} */ s, /** @type {any} */ sI) => {
        if (w.globals.axisCharts) {
          handleAxisRowsColumns(s, sI)
        } else {
          columns = []

          columns.push(getFormattedCategory(w.labelData.labels[sI]))
          columns.push(getFormattedValue(gSeries[sI]))
          rows.push(columns.join(columnDelimiter))
        }
      })
    }

    result += rows.join(lineDelimiter)

    this.triggerDownload(
      'data:text/csv; charset=utf-8,' +
        encodeURIComponent(universalBOM + result),
      fileName ? fileName : w.config.chart.toolbar.export.csv.filename,
      '.csv',
    )
  }

  /**
   * @param {string} href
   * @param {string} filename
   * @param {string} ext
   */
  triggerDownload(href, filename, ext) {
    if (Environment.isSSR()) return

    const downloadLink = document.createElement('a')
    downloadLink.href = href
    downloadLink.download = (filename ? filename : this.w.globals.chartID) + ext
    document.body.appendChild(downloadLink)
    downloadLink.click()
    document.body.removeChild(downloadLink)
  }
}

export default Exports
