/*
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
'use strict';

import EventListenerManager from '/extlib/EventListenerManager.js';

import {
  log as internalLogger,
  configs,
  notify,
  wait,
  getChunkedConfig,
  setChunkedConfig,
  isLinux,
} from './common.js';
import * as Constants from '/common/constants.js';
import * as TreeBehavior from '/common/tree-behavior.js';

import { Tab } from '/common/TreeItem.js';

function log(...args) {
  internalLogger('common/sync', ...args);
}

export const onMessage = new EventListenerManager();
export const onNewDevice = new EventListenerManager();
export const onUpdatedDevice = new EventListenerManager();
export const onObsoleteDevice = new EventListenerManager();

const SEND_TABS_SIMULATOR_ID = 'send-tabs-to-device-simulator@piro.sakura.ne.jp';

// Workaround for https://github.com/piroor/treestyletab/blob/trunk/README.md#user-content-feature-requests-send-tab-tree-to-device-does-not-work
// and https://bugzilla.mozilla.org/show_bug.cgi?id=1417183 (resolved as WONTFIX)
// Firefox does not provide any API to access to Sync features directly.
// We need to provide it as experiments API or something way.
// This module is designed to work with a service provide which has features:
//   * async getOtherDevices()
//     - Returns an array of sync devices.
//     - Retruned array should have 0 or more items like:
//       { id:   "identifier of the device",
//         name: "name of the device",
//         type: "type of the device (desktop, mobile, and so on)" }
//   * sendTabsToDevice(tabs, deviceId)
//     - Returns nothing.
//     - Sends URLs of given tabs to the specified device.
//     - The device is one of values returned by getOtherDevices().
//   * sendTabsToAllDevices(tabs)
//     - Returns nothing.
//     - Sends URLs of given tabs to all other devices.
//   * manageDevices(windowId)
//     - Returns nothing.
//     - Opens settings page for Sync devices.
//       It will appear as a tab in the specified window.

let mExternalProvider = null;

export function registerExternalProvider(provider) {
  mExternalProvider = provider;
}

export function hasExternalProvider() {
  return !!mExternalProvider;
}

let mMyDeviceInfo = null;

async function getMyDeviceInfo() {
  if (!configs.syncDeviceInfo || !configs.syncDeviceInfo.id) {
    const newDeviceInfo = await generateDeviceInfo();
    if (!configs.syncDeviceInfo)
      configs.syncDeviceInfo = newDeviceInfo;
  }
  return mMyDeviceInfo = configs.syncDeviceInfo;
}

async function ensureDeviceInfoInitialized() {
  await getMyDeviceInfo();
}

export async function waitUntilDeviceInfoInitialized() {
  while (!configs.syncDeviceInfo) {
    await wait(100);
  }
  mMyDeviceInfo = configs.syncDeviceInfo;
}

let initialized = false;
let preChanges = [];

function onConfigChanged(key, value = undefined) {
  if (!initialized) {
    preChanges.push({ key, value: value === undefined ? configs[key] : value });
    return;
  }
  switch (key) {
    case 'syncOtherDevicesDetected':
      if (!configs.syncAvailableNotified) {
        configs.syncAvailableNotified = true;
        notify({
          title:   browser.i18n.getMessage('syncAvailable_notification_title'),
          message: browser.i18n.getMessage(`syncAvailable_notification_message${isLinux() ? '_linux' : ''}`),
          url:     `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions`,
          timeout: configs.syncAvailableNotificationTimeout
        });
      }
      return;

    case 'syncDevices':
      // This may happen when all configs are reset.
      // We need to try updating devices after syncDeviceInfo is completely cleared.
      wait(100).then(updateDevices);
      break;

    case 'syncDeviceInfo':
      if (configs.syncDeviceInfo &&
          mMyDeviceInfo &&
          configs.syncDeviceInfo.id == mMyDeviceInfo.id &&
          configs.syncDeviceInfo.timestamp == mMyDeviceInfo.timestamp)
        return; // ignore updating triggered by myself
      mMyDeviceInfo = null;
      updateSelf();
      break;

    default:
      if (key.startsWith('chunkedSyncData'))
        reserveToReceiveMessage();
      break;
  }
}

browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (!initialized ||
      !message ||
      typeof message != 'object' ||
      typeof message.type != 'string' ||
      sender.id != SEND_TABS_SIMULATOR_ID)
    return;

  switch (message.type) {
    case 'ready':
      try {
        browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'register-self' }).catch(_error => {});
      }
      catch(_error) {
      }
    case 'device-added':
    case 'device-updated':
    case 'device-removed':
      updateSelf();
      break;
  }
});

export async function init() {
  configs.$addObserver(onConfigChanged); // we need to register the listener to collect pre-sent changes
  await configs.$loaded;
  await ensureDeviceInfoInitialized();
  await updateSelf();
  initialized = true;

  reserveToReceiveMessage();
  window.setInterval(updateSelf, 1000 * 60 * 60 * 24); // update info every day!

  for (const change of preChanges) {
    onConfigChanged(change.key, change.value);
  }
  preChanges = [];

  try {
    browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'register-self' }).catch(_error => {});
  }
  catch(_error) {
  }
}

export async function generateDeviceInfo({ name, icon } = {}) {
  const [platformInfo, browserInfo] = await Promise.all([
    browser.runtime.getPlatformInfo(),
    browser.runtime.getBrowserInfo()
  ]);
  return {
    id:   `device-${Date.now()}-${Math.round(Math.random() * 65000)}`,
    name: name === undefined ?
      browser.i18n.getMessage('syncDeviceDefaultName', [toHumanReadableOSName(platformInfo.os), browserInfo.name]) :
      (name || null),
    icon: icon || 'device-desktop'
  };
}

// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/PlatformOs
function toHumanReadableOSName(os) {
  switch (os) {
    case 'mac': return 'macOS';
    case 'win': return 'Windows';
    case 'android': return 'Android';
    case 'cros': return 'Chrome OS';
    case 'linux': return 'Linux';
    case 'openbsd': return 'Open/FreeBSD';
    default: return 'Unknown Platform';
  }
}

configs.$addObserver(key => {
  switch (key) {
    case 'syncUnsendableUrlPattern':
      isSendableTab.unsendableUrlMatcher = null;
      break;

    default:
      break;
  }
});

async function updateSelf() {
  if (updateSelf.updating)
    return;

  updateSelf.updating = true;

  const [devices] = await Promise.all([
    (async () => {
      try {
        return await browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'list-devices' });
      }
      catch(_error) {
      }
    })(),
    ensureDeviceInfoInitialized(),
  ]);
  if (devices) {
    const myDeviceFromSimulator = devices.find(device => device.myself);
    if (mMyDeviceInfo.simulatorId != myDeviceFromSimulator.id)
      mMyDeviceInfo.simulatorId = myDeviceFromSimulator.id;
  }

  configs.syncDeviceInfo = mMyDeviceInfo = {
    ...clone(configs.syncDeviceInfo),
    timestamp: Date.now(),
  };

  await updateDevices();

  setTimeout(() => {
    updateSelf.updating = false;
  }, 250);
}

async function updateDevices() {
  if (updateDevices.updating)
    return;
  updateDevices.updating = true;
  const [devicesFromSimulator] = await Promise.all([
    (async () => {
      try {
        return await browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'list-devices' });
      }
      catch(_error) {
      }
    })(),
    waitUntilDeviceInfoInitialized(),
  ]);

  const remote = clone(configs.syncDevices);
  const local  = clone(configs.syncDevicesLocalCache);
  log('devices updated: ', local, remote);
  for (const [id, info] of Object.entries(remote)) {
    if (id == mMyDeviceInfo.id)
      continue;
    local[id] = info;
    if (id in local) {
      log('updated device: ', info);
      onUpdatedDevice.dispatch(info);
    }
    else {
      log('new device: ', info);
      onNewDevice.dispatch(info);
    }
  }

  if (devicesFromSimulator) {
    const knownDeviceIdsFromSimulator = new Set(Object.values(local).map(device => device.simulatorId).filter(id => !!id));
    for (const deviceFromSimulator of devicesFromSimulator) {
      if (knownDeviceIdsFromSimulator.has(deviceFromSimulator.id))
        continue;
      const localId = `device-from-simulator:${deviceFromSimulator.id}`;
      local[localId] = {
        ...deviceFromSimulator,
        id: localId,
      };
    }
  }

  for (const [id, info] of Object.entries(local)) {
    if (id in remote ||
        id == mMyDeviceInfo.id)
      continue;
    log('obsolete device: ', info);
    delete local[id];
    onObsoleteDevice.dispatch(info);
  }

  if (configs.syncDeviceExpirationDays > 0) {
    const expireDateInSeconds = Date.now() - (1000 * 60 * 60 * configs.syncDeviceExpirationDays);
    for (const [id, info] of Object.entries(local)) {
      if (info &&
          info.timestamp < expireDateInSeconds) {
        delete local[id];
        log('expired device: ', info);
        onObsoleteDevice.dispatch(info);
      }
    }
  }

  local[mMyDeviceInfo.id] = clone(mMyDeviceInfo);
  log('store myself: ', mMyDeviceInfo, local[mMyDeviceInfo.id]);

  if (!configs.syncOtherDevicesDetected && Object.keys(local).length > 1)
    configs.syncOtherDevicesDetected = true;

  configs.syncDevices = local;
  configs.syncDevicesLocalCache = clone(local);
  setTimeout(() => {
    updateDevices.updating = false;
  }, 250);
}

function reserveToReceiveMessage() {
  if (reserveToReceiveMessage.reserved)
    clearTimeout(reserveToReceiveMessage.reserved);
  reserveToReceiveMessage.reserved = setTimeout(() => {
    delete reserveToReceiveMessage.reserved;
    receiveMessage();
  }, 250);
}

async function receiveMessage() {
  const myDeviceInfo = await getMyDeviceInfo();
  try {
    const messages = readMessages();
    log('receiveMessage: queued messages => ', messages);
    const restMessages = messages.filter(message => {
      if (message.timestamp <= configs.syncLastMessageTimestamp)
        return false;
      if (message.to == myDeviceInfo.id) {
        log('receiveMessage receive: ', message);
        configs.syncLastMessageTimestamp = message.timestamp;
        onMessage.dispatch(message);
        return false;
      }
      return true;
    });
    log('receiveMessage: restMessages => ', restMessages);
    if (restMessages.length != messages.length)
      writeMessages(restMessages);
  }
  catch(error) {
    log('receiveMessage fatal error: ', error);
    writeMessages([]);
  }
}

export async function sendMessage(to, data) {
  const myDeviceInfo = await getMyDeviceInfo();
  try {
    const messages = readMessages();
    messages.push({
      timestamp: Date.now(),
      from:      myDeviceInfo.id,
      to,
      data
    });
    log('sendMessage: queued messages => ', messages);
    writeMessages(messages);
  }
  catch(error) {
    console.log('Sync.sendMessage: failed to send message ', error);
    writeMessages([]);
  }
}

function readMessages() {
  try {
    return uniqMessages([
      ...JSON.parse(getChunkedConfig('chunkedSyncDataLocal') || '[]'),
      ...JSON.parse(getChunkedConfig('chunkedSyncData') || '[]')
    ]);
  }
  catch(error) {
    log('failed to read messages: ', error);
    return [];
  }
}

function writeMessages(messages) {
  const stringified = JSON.stringify(messages || []);
  setChunkedConfig('chunkedSyncDataLocal', stringified);
  setChunkedConfig('chunkedSyncData', stringified);
}

function uniqMessages(messages) {
  const knownMessages = new Set();
  return messages.filter(message => {
    const key = JSON.stringify(message);
    if (knownMessages.has(key))
      return false;
    knownMessages.add(key);
    return true;
  });
}

function clone(value) {
  return JSON.parse(JSON.stringify(value));
}

export async function getOtherDevices() {
  if (mExternalProvider)
    return mExternalProvider.getOtherDevices();

  await waitUntilDeviceInfoInitialized();

  const devices = configs.syncDevices || {};
  const result = [];
  for (const [id, info] of Object.entries(devices)) {
    if (id == mMyDeviceInfo.id ||
        !info.id /* ignore invalid device info accidentally saved (see also https://github.com/piroor/treestyletab/issues/2922 ) */)
      continue;
    result.push(info);
  }
  return result.sort((a, b) => a.name > b.name);
}

export function getDeviceName(id) {
  const devices = configs.syncDevices || {};
  if (!(id in devices) || !devices[id])
    return browser.i18n.getMessage('syncDeviceUnknownDevice');
  return String(devices[id].name || '').trim() || browser.i18n.getMessage('syncDeviceMissingDeviceName');
}

// https://searchfox.org/mozilla-central/rev/d866b96d74ec2a63f09ee418f048d23f4fd379a2/browser/base/content/browser-sync.js#1176
export function isSendableTab(tab) {
  if (!tab.url ||
      tab.url.length > 65535)
    return false;

  if (!isSendableTab.unsendableUrlMatcher)
    isSendableTab.unsendableUrlMatcher = new RegExp(configs.syncUnsendableUrlPattern);
  return !isSendableTab.unsendableUrlMatcher.test(tab.url);
}

export function sendTabsToDevice(tabs, { to, recursively } = {}) {
  if (recursively)
    tabs = Tab.collectRootTabs(tabs).map(tab => [tab, ...tab.$TST.descendants]).flat();
  tabs = tabs.filter(isSendableTab);

  if (mExternalProvider)
    return mExternalProvider.sendTabsToDevice(tabs, to);

  sendMessage(to, getTabsDataToSend(tabs));

  const multiple = tabs.length > 1 ? '_multiple' : '';
  notify({
    title: browser.i18n.getMessage(
      `sentTabs_notification_title${multiple}`,
      [getDeviceName(to)]
    ),
    message: browser.i18n.getMessage(
      `sentTabs_notification_message${multiple}`,
      [getDeviceName(to)]
    ),
    timeout: configs.syncSentTabsNotificationTimeout
  });
}

export async function sendTabsToAllDevices(tabs, { recursively } = {}) {
  if (recursively)
    tabs = Tab.collectRootTabs(tabs).map(tab => [tab, ...tab.$TST.descendants]).flat();
  tabs = tabs.filter(isSendableTab);

  if (mExternalProvider)
    return mExternalProvider.sendTabsToAllDevices(tabs);

  const data = getTabsDataToSend(tabs);
  const devices = await getOtherDevices();
  for (const device of devices) {
    sendMessage(device.id, data);
  }

  const multiple = tabs.length > 1 ? '_multiple' : '';
  notify({
    title:   browser.i18n.getMessage(`sentTabsToAllDevices_notification_title${multiple}`),
    message: browser.i18n.getMessage(`sentTabsToAllDevices_notification_message${multiple}`),
    timeout: configs.syncSentTabsNotificationTimeout
  });
}

function getTabsDataToSend(tabs) {
  return {
    type:       Constants.kSYNC_DATA_TYPE_TABS,
    tabs:       tabs.map(tab => ({ url: tab.url, cookieStoreId: tab.cookieStoreId })),
    structure : TreeBehavior.getTreeStructureFromTabs(tabs).map(item => item.parent)
  };
}

export function manageDevices(windowId) {
  if (mExternalProvider)
    return mExternalProvider.manageDevices(windowId);

  browser.tabs.create({
    windowId,
    url: `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions`
  });
}
