const get = require('lodash/get')
const map = require('lodash/map')
const pick = require('lodash/pick')
const once = require('lodash/once')
const Promise = require('bluebird')
const browser = require('webextension-polyfill')
const { cookieMatches } = require('../../server/lib/automation/util')

const client = require('./client')
const util = require('../lib/util')

const COOKIE_PROPS = ['url', 'name', 'path', 'secure', 'domain']
const GET_ALL_PROPS = COOKIE_PROPS.concat(['session', 'storeId'])
// https://developer.chrome.com/extensions/cookies#method-set
const SET_PROPS = COOKIE_PROPS.concat(['value', 'httpOnly', 'expirationDate', 'sameSite'])

const httpRe = /^http/

// normalize into null when empty array
const firstOrNull = (cookies) => {
  return cookies[0] != null ? cookies[0] : null
}

const checkIfFirefox = async () => {
  if (!browser || !get(browser, 'runtime.getBrowserInfo')) {
    return false
  }

  const { name } = await browser.runtime.getBrowserInfo()

  return name === 'Firefox'
}

const connect = function (host, path, extraOpts) {
  const listenToCookieChanges = once(() => {
    return browser.cookies.onChanged.addListener((info) => {
      if (info.cause !== 'overwrite') {
        return ws.emit('automation:push:request', 'change:cookie', info)
      }
    })
  })

  const listenToDownloads = once(() => {
    browser.downloads.onCreated.addListener((downloadItem) => {
      ws.emit('automation:push:request', 'create:download', {
        id: `${downloadItem.id}`,
        filePath: downloadItem.filename,
        mime: downloadItem.mime,
        url: downloadItem.url,
      })
    })

    browser.downloads.onChanged.addListener((downloadDelta) => {
      if ((downloadDelta.state || {}).current !== 'complete') return

      ws.emit('automation:push:request', 'complete:download', {
        id: `${downloadDelta.id}`,
      })
    })
  })

  const listenToOnBeforeHeaders = once(() => {
    // adds a header to the request to mark it as a request for the AUT frame
    // itself, so the proxy can utilize that for injection purposes
    browser.webRequest.onBeforeSendHeaders.addListener((details) => {
      if (
        // parentFrameId: 0 means the parent is the top-level, so if it isn't
        // 0, it's nested inside the AUT and can't be the AUT itself
        details.parentFrameId !== 0
        // is the spec frame, not the AUT
        || details.url.includes('__cypress')
      ) return

      return {
        requestHeaders: [
          ...details.requestHeaders,
          {
            name: 'X-Cypress-Is-AUT-Frame',
            value: 'true',
          },
        ],
      }
    }, { urls: ['<all_urls>'], types: ['sub_frame'] }, ['blocking', 'requestHeaders'])
  })

  const fail = (id, err) => {
    return ws.emit('automation:response', id, {
      __error: err.message,
      __stack: err.stack,
      __name: err.name,
    })
  }

  const invoke = function (method, id, ...args) {
    const respond = (data) => {
      return ws.emit('automation:response', id, { response: data })
    }

    return Promise.try(() => {
      return automation[method].apply(automation, args.concat(respond))
    }).catch((err) => {
      return fail(id, err)
    })
  }

  const ws = client.connect(host, path, extraOpts)

  ws.on('automation:request', (id, msg, data) => {
    switch (msg) {
      case 'get:cookies':
        return invoke('getCookies', id, data)
      case 'get:cookie':
        return invoke('getCookie', id, data)
      case 'set:cookie':
        return invoke('setCookie', id, data)
      case 'set:cookies':
      case 'add:cookies':
        return invoke('setCookies', id, data)
      case 'clear:cookies':
        return invoke('clearCookies', id, data)
      case 'clear:cookie':
        return invoke('clearCookie', id, data)
      case 'is:automation:client:connected':
        return invoke('verify', id, data)
      case 'focus:browser:window':
        return invoke('focus', id)
      case 'take:screenshot':
        return invoke('takeScreenshot', id)
      case 'reset:browser:state':
        return invoke('resetBrowserState', id)
      case 'reset:browser:tabs:for:next:test':
        return invoke('resetBrowserTabsForNextTest', id)
      default:
        return fail(id, { message: `No handler registered for: '${msg}'` })
    }
  })

  ws.on('automation:config', async (config) => {
    const isFirefox = await checkIfFirefox()

    listenToCookieChanges()
    // Non-Firefox browsers use CDP for these instead
    if (isFirefox) {
      listenToDownloads()
      listenToOnBeforeHeaders()
    }
  })

  ws.on('connect', () => {
    ws.emit('automation:client:connected')
  })

  return ws
}

const setOneCookie = (props) => {
  // only get the url if its not already set
  if (props.url == null) {
    props.url = util.getCookieUrl(props)
  }

  if (props.hostOnly) {
    // If the hostOnly prop is available, delete the domain.
    // This will wind up setting a hostOnly cookie based on the calculated cookieURL above.
    delete props.domain
  }

  if (props.domain === 'localhost') {
    delete props.domain
  }

  props = pick(props, SET_PROPS)

  return Promise.try(() => {
    return browser.cookies.set(props)
  })
}

const clearOneCookie = (cookie = {}) => {
  const url = util.getCookieUrl(cookie)
  const props = { url, name: cookie.name }

  const throwError = function (err) {
    throw (err != null ? err : new Error(`Removing cookie failed for: ${JSON.stringify(props)}`))
  }

  return Promise.try(() => {
    if (!cookie.name) {
      throw new Error(`Removing cookie failed for: ${JSON.stringify(cookie)}. Cookie did not include a name`)
    }

    return browser.cookies.remove(props)
  }).then((details) => {
    return cookie
  }).catch(throwError)
}

const clearAllCookies = (cookies) => {
  return Promise.mapSeries(cookies, clearOneCookie)
}

const automation = {
  connect,

  getAll (filter = {}) {
    filter = pick(filter, GET_ALL_PROPS)

    // Firefox's filtering doesn't match the behavior we want, so we do it
    // ourselves. for example, getting { domain: example.com } cookies will
    // return cookies for example.com and all subdomains, whereas we want an
    // exact match for only "example.com".
    return Promise.try(() => {
      return browser.cookies.getAll({ url: filter.url })
      .then((cookies) => {
        return cookies.filter((cookie) => {
          return cookieMatches(cookie, filter)
        })
      })
    })
  },

  getCookies (filter, fn) {
    return this.getAll(filter)
    .then(fn)
  },

  getCookie (filter, fn) {
    return this.getAll(filter)
    .then(firstOrNull)
    .then(fn)
  },

  setCookie (props = {}, fn) {
    return setOneCookie(props)
    .then(fn)
  },

  setCookies (propsArr = [], fn) {
    return Promise.mapSeries(propsArr, setOneCookie)
    .then(fn)
  },

  clearCookie (filter, fn) {
    return this.getCookie(filter)
    .then((cookie) => {
      if (!cookie) return null

      return clearOneCookie(cookie)
    })
    .then(fn)
  },

  clearCookies (cookies, fn) {
    return clearAllCookies(cookies)
    .then(fn)
  },

  focus (fn) {
    // lets just make this simple and whatever is the current
    // window bring that into focus
    //
    // TODO: if we REALLY want to be nice its possible we can
    // figure out the exact window that's running Cypress but
    // that's too much work with too little value at the moment
    return Promise.try(() => {
      return browser.windows.getCurrent()
    }).then((window) => {
      return browser.windows.update(window.id, { focused: true })
    }).then(fn)
  },

  resetBrowserState (fn) {
    // We remove browser data. Firefox goes through this path, while chrome goes through cdp automation
    // Note that firefox does not support fileSystems or serverBoundCertificates
    // (https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/DataTypeSet).
    return browser.browsingData.remove({}, { cache: true, cookies: true, downloads: true, formData: true, history: true, indexedDB: true, localStorage: true, passwords: true, pluginData: true, serviceWorkers: true }).then(fn)
  },

  resetBrowserTabsForNextTest (fn) {
    return Promise.try(() => {
      return browser.tabs.create({ url: 'about:blank' })
    }).then(() => {
      return browser.windows.getCurrent({ populate: true })
    }).then((windowInfo) => {
      return browser.tabs.remove(windowInfo.tabs.map((tab) => tab.id))
    }).then(fn)
  },

  query (data) {
    const code = `var s; (s = document.getElementById('${data.element}')) && s.textContent`

    const queryTab = (tab) => {
      return Promise.try(() => {
        return browser.tabs.executeScript(tab.id, { code })
      }).then((results) => {
        if (!results || (results[0] !== data.randomString)) {
          throw new Error('Executed script did not return result')
        }
      })
    }

    return Promise.try(() => {
      return browser.tabs.query({ windowType: 'normal' })
    }).filter((tab) => {
      // the tab's url must begin with
      // http or https so that we filter out
      // about:blank and chrome:// urls
      // which will throw errors!
      return httpRe.test(tab.url)
    }).then((tabs) => {
      // generate array of promises
      return map(tabs, queryTab)
    }).any()
  },

  verify (data, fn) {
    return this.query(data)
    .then(fn)
  },

  lastFocusedWindow () {
    return Promise.try(() => {
      return browser.windows.getLastFocused()
    })
  },

  takeScreenshot (fn) {
    return this.lastFocusedWindow()
    .then((win) => {
      return browser.tabs.captureVisibleTab(win.id, { format: 'png' })
    })
    .then(fn)
  },

}

module.exports = automation
