text: const pMap = require('p-map')
  const axios = require('axios')
  const camelCase = require('camelcase')
  const { mapKeys, isPlainObject, trimEnd, trimStart } = require('lodash')
  
  const TYPE_AUTHOR = 'author'
  const TYPE_ATTACHEMENT = 'attachment'
  
  class WordPressSource {
    static defaultOptions () {
      return {
        baseUrl: '',
        apiBase: 'wp-json',
        perPage: 100,
        concurrent: 10,
        typeName: 'WordPress'
      }
    }
  
    constructor (api, options) {
      this.options = options
      this.restBases = { posts: {}, taxonomies: {}}
  
      if (!options.typeName) {
        throw new Error(`Missing typeName option.`)
      }
  
      if (options.perPage > 100 || options.perPage < 1) {
        throw new Error(`${options.typeName}: perPage cannot be more than 100 or less than 1.`)
      }
  
      this.customEndpoints = this.sanitizeCustomEndpoints()
  
      const baseUrl = trimEnd(options.baseUrl, '/')
  
      this.client = axios.create({
        baseURL: `${baseUrl}/${options.apiBase}`
      })
  
      this.routes = this.options.routes || {}
  
      api.loadSource(async actions => {
        this.store = actions
  
        console.log(`Loading data from ${baseUrl}`)
  
        await this.getPostTypes(actions)
        await this.getUsers(actions)
        await this.getTaxonomies(actions)
        await this.getPosts(actions)
        await this.getCustomEndpoints(actions)
      })
    }
  
    async getPostTypes (actions) {
      const { data } = await this.fetch('wp/v2/types', {}, {})
      const addCollection = actions.addCollection || actions.addContentType
  
      for (const type in data) {
        const options = data[type]
  
        this.restBases.posts[type] = trimStart(options.rest_base, '/')
  
        addCollection({
          typeName: this.createTypeName(type),
          route: this.routes[type]
        })
      }
    }
  
    async getUsers (actions) {
      const { data } = await this.fetch('wp/v2/users')
      const addCollection = actions.addCollection || actions.addContentType
  
      const authors = addCollection({
        typeName: this.createTypeName(TYPE_AUTHOR),
        route: this.routes.author
      })
  
      for (const author of data) {
        const fields = this.normalizeFields(author)
        const avatars = mapKeys(author.avatar_urls, (v, key) => `avatar${key}`)
  
        authors.addNode({
          ...fields,
          id: author.id,
          title: author.name,
          avatars
        })
      }
    }
  
    async getTaxonomies (actions) {
      const { data } = await this.fetch('wp/v2/taxonomies', {}, {})
      const addCollection = actions.addCollection || actions.addContentType
  
      for (const type in data) {
        const options = data[type]
        const taxonomy = addCollection({
          typeName: this.createTypeName(type),
          route: this.routes[type]
        })
  
        this.restBases.taxonomies[type] = trimStart(options.rest_base, '/')
  
        const terms = await this.fetchPaged(`wp/v2/${options.rest_base}`)
  
        for (const term of terms) {
          taxonomy.addNode({
            id: term.id,
            title: term.name,
            slug: term.slug,
            content: term.description,
            meta: term.meta,
            count: term.count
          })
        }
      }
    }
  
    async getPosts (actions) {
      const { createReference } = actions
      const getCollection = actions.getCollection || actions.getContentType
  
      const AUTHOR_TYPE_NAME = this.createTypeName(TYPE_AUTHOR)
      const ATTACHEMENT_TYPE_NAME = this.createTypeName(TYPE_ATTACHEMENT)
  
      for (const type in this.restBases.posts) {
        const restBase = this.restBases.posts[type]
        const typeName = this.createTypeName(type)
        const posts = getCollection(typeName)
  
        const data = await this.fetchPaged(`wp/v2/${restBase}`)
  
        for (const post of data) {
          const fields = this.normalizeFields(post)
  
          fields.author = createReference(AUTHOR_TYPE_NAME, post.author || '0')
  
          if (post.type !== TYPE_ATTACHEMENT) {
            fields.featuredMedia = createReference(ATTACHEMENT_TYPE_NAME, post.featured_media)
          }
  
          // add references if post has any taxonomy rest bases as properties
          for (const type in this.restBases.taxonomies) {
            const propName = this.restBases.taxonomies[type]
  
            if (post.hasOwnProperty(propName)) {
              const typeName = this.createTypeName(type)
              const key = camelCase(propName)
  
              fields[key] = Array.isArray(post[propName])
                ? post[propName].map(id => createReference(typeName, id))
                : createReference(typeName, post[propName])
            }
          }
  
          posts.addNode({ ...fields, id: post.id })
        }
      }
    }
  
    async getCustomEndpoints (actions) {
      for (const endpoint of this.customEndpoints) {
        const makeCollection = actions.addCollection || actions.addContentType
        const cepCollection = makeCollection({
          typeName: endpoint.typeName
        })
        const { data } = await this.fetch(endpoint.route, {}, {})
        for (let item of data) {
          if (endpoint.normalize) {
            item = this.normalizeFields(item)
          }
  
          cepCollection.addNode({
            ...item,
            id: item.id || item.slug
          })
        }
      }
    }
  
    async fetch (url, params = {}, fallbackData = []) {
      let res
  
      try {
        res = await this.client.request({ url, params })
      } catch ({ response, code, config }) {
        if (!response && code) {
          throw new Error(`${code} - ${config.url}`)
        }
  
        const { url } = response.config
        const { status } = response.data.data
  
        if ([401, 403].includes(status)) {
          console.warn(`Error: Status ${status} - ${url}`)
          return { ...response, data: fallbackData }
        } else {
          throw new Error(`${status} - ${url}`)
        }
      }
  
      return res
    }
  
    async fetchPaged (path) {
      const { perPage, concurrent } = this.options
  
      return new Promise(async (resolve, reject) => {
        let res
  
        try {
          res = await this.fetch(path, { per_page: perPage })
        } catch (err) {
          return reject(err)
        }
  
        const totalItems = parseInt(res.headers['x-wp-total'], 10)
        const totalPages = parseInt(res.headers['x-wp-totalpages'], 10)
  
        try {
          res.data = ensureArrayData(path, res.data)
        } catch (err) {
          return reject(err)
        }
  
        if (!totalItems || totalPages <= 1) {
          return resolve(res.data)
        }
  
        const queue = []
  
        for (let page = 2; page <= totalPages; page++) {
          queue.push({ per_page: perPage, page })
        }
  
        await pMap(queue, async params => {
          try {
            const { data } = await this.fetch(path, params)
            res.data.push(...ensureArrayData(path, data))
          } catch (err) {
            console.log(err.message)
          }
        }, { concurrency: concurrent })
  
        resolve(res.data)
      })
    }
  
    sanitizeCustomEndpoints () {
      if (!this.options.customEndpoints) return []
      if (!Array.isArray(this.options.customEndpoints)) throw Error('customEndpoints must be an array')
      this.options.customEndpoints.forEach(endpoint => {
        if (!endpoint.typeName) {
          throw Error('Please provide a typeName option for all customEndpoints\n')
        }
        if (!endpoint.route) {
          throw Error(`No route option in endpoint: ${endpoint.typeName}\n Ex: 'apiName/versionNumber/endpointObject'`)
        }
      })
      return this.options.customEndpoints ? this.options.customEndpoints : []
    }
  
    normalizeFields (fields) {
      const res = {}
  
      for (const key in fields) {
        if (key.startsWith('_')) continue // skip links and embeds etc
        res[camelCase(key)] = this.normalizeFieldValue(fields[key])
      }
  
      return res
    }
  
    normalizeFieldValue (value) {
      if (value === null) return null
      if (value === undefined) return null
  
      if (Array.isArray(value)) {
        return value.map(v => this.normalizeFieldValue(v))
      }
  
      if (isPlainObject(value)) {
        if (value.post_type && (value.ID || value.id)) {
          const typeName = this.createTypeName(value.post_type)
          const id = value.ID || value.id
  
          return this.store.createReference(typeName, id)
        } else if (value.filename && (value.ID || value.id)) {
          const typeName = this.createTypeName(TYPE_ATTACHEMENT)
          const id = value.ID || value.id
  
          return this.store.createReference(typeName, id)
        } else if (value.hasOwnProperty('rendered')) {
          return value.rendered
        }
  
        return this.normalizeFields(value)
      }
  
      return value
    }
  
    createTypeName (name = '') {
      return camelCase(`${this.options.typeName} ${name}`, { pascalCase: true })
    }
  }
  
  function ensureArrayData (url, data) {
    if (!Array.isArray(data)) {
      try {
        data = JSON.parse(data)
      } catch (err) {
        throw new Error(
          `Failed to fetch ${url}\n` +
          `Expected JSON response but received:\n` +
          `${data.trim().substring(0, 150)}...\n`
        )
      }
    }
    return data
  }
  
  module.exports = WordPressSource
  