import assert from 'assert';
import { ChildProcess } from 'child_process';
import Protocol from 'devtools-protocol';
import findRoot from 'find-root';
import path from 'path';
import { decorateBrowser, ExtensionBridge, NullExtensionBridge } from 'puppeteer-extensionbridge';
import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser';
import { Connection } from 'puppeteer/lib/cjs/puppeteer/common/Connection';
import { Events } from 'puppeteer/lib/cjs/puppeteer/common/Events';
import { Viewport } from 'puppeteer/lib/cjs/puppeteer/common/PuppeteerViewport';
import { Target } from 'puppeteer/lib/cjs/puppeteer/common/Target';
import Logger from '../util/logger';
import { HackiumBrowserContext } from './hackium-browser-context';
import { HackiumPage } from './hackium-page';
import { HackiumTarget, TargetEmittedEvents } from './hackium-target';

const newTabTimeout = 500;

export enum HackiumBrowserEmittedEvents {
  ActivePageChanged = 'activePageChanged',
}

export type BrowserCloseCallback = () => Promise<void> | void;

export class HackiumBrowser extends Browser {
  log: Logger = new Logger('hackium:browser');
  activePage?: HackiumPage;
  connection: Connection;
  extension: ExtensionBridge = new NullExtensionBridge();
  _targets: Map<string, HackiumTarget> = new Map();
  __defaultContext: HackiumBrowserContext;
  __contexts: Map<string, HackiumBrowserContext> = new Map();
  __ignoreHTTPSErrors: boolean;
  __defaultViewport?: Viewport;
  newtab = `file://${path.join(findRoot(__dirname), 'pages', 'homepage', 'index.html')}`;

  constructor(
    connection: Connection,
    contextIds: string[],
    ignoreHTTPSErrors: boolean,
    defaultViewport?: Viewport,
    process?: ChildProcess,
    closeCallback?: BrowserCloseCallback,
  ) {
    super(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback);
    this.connection = connection;
    this.__ignoreHTTPSErrors = ignoreHTTPSErrors;
    this.__defaultViewport = defaultViewport;
    this.__defaultContext = new HackiumBrowserContext(this.connection, this);
    this.__contexts = new Map();
    for (const contextId of contextIds) this.__contexts.set(contextId, new HackiumBrowserContext(this.connection, this, contextId));
    const listenerCount = this.connection.listenerCount('Target.targetCreated');

    if (listenerCount === 1) {
      this.connection.removeAllListeners('Target.targetCreated');
      this.connection.on('Target.targetCreated', this.__targetCreated.bind(this));
    } else {
      throw new Error('Need to reimplement how to intercept target creation. Submit a PR with a reproducible test case.');
    }
    this.log.debug('Hackium browser created');
  }

  async initialize() {
    await this.waitForTarget((target: Target) => target.type() === 'page');
    const [page] = await this.pages();
    this.setActivePage(page);
  }

  async pages(): Promise<HackiumPage[]> {
    const contextPages = await Promise.all(this.browserContexts().map((context) => context.pages()));
    return contextPages.reduce((acc, x) => acc.concat(x), []);
  }

  async newPage(): Promise<HackiumPage> {
    return this.__defaultContext.newPage();
  }

  browserContexts(): HackiumBrowserContext[] {
    return [this.__defaultContext, ...Array.from(this.__contexts.values())];
  }

  async createIncognitoBrowserContext(): Promise<HackiumBrowserContext> {
    const { browserContextId } = await this.connection.send('Target.createBrowserContext');
    const context = new HackiumBrowserContext(this.connection, this, browserContextId);
    this.__contexts.set(browserContextId, context);
    return context;
  }

  async _disposeContext(contextId?: string): Promise<void> {
    if (contextId) {
      await this.connection.send('Target.disposeBrowserContext', {
        browserContextId: contextId,
      });
      this.__contexts.delete(contextId);
    }
  }

  defaultBrowserContext(): HackiumBrowserContext {
    return this.__defaultContext;
  }

  async __targetCreated(event: Protocol.Target.TargetCreatedEvent): Promise<void> {
    const targetInfo = event.targetInfo;
    const { browserContextId } = targetInfo;

    const context =
      browserContextId && this.__contexts.has(browserContextId) ? this.__contexts.get(browserContextId) : this.__defaultContext;

    if (!context) throw new Error('Brower context should not be null or undefined');
    this.log.debug('Creating new target %o', targetInfo);
    const target = new HackiumTarget(
      targetInfo,
      context,
      () => this.connection.createSession(targetInfo),
      this.__ignoreHTTPSErrors,
      this.__defaultViewport || null,
    );

    assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated');
    this._targets.set(event.targetInfo.targetId, target);

    if (targetInfo.url === 'chrome://newtab/') {
      this.log.debug('New tab opened, waiting for it to navigate to custom newtab');
      await new Promise((resolve, reject) => {
        let done = false;
        const changedHandler = (targetInfo: Protocol.Target.TargetInfo) => {
          this.log.debug('New tab target info changed %o', targetInfo);
          if (targetInfo.url === this.newtab) {
            this.log.debug('New tab navigation complete, continuing');
            resolve();
            target.off(TargetEmittedEvents.TargetInfoChanged, changedHandler);
          }
        };
        target.on(TargetEmittedEvents.TargetInfoChanged, changedHandler);
        setTimeout(() => {
          this.log.debug(`New tab navigation timed out.`);
          if (!done) reject(`Timeout of ${newTabTimeout} exceeded`);
          target.off(TargetEmittedEvents.TargetInfoChanged, changedHandler);
        }, newTabTimeout);
      });
    }

    if (targetInfo.type === 'page') {
      // page objects are lazily created, so merely accessing this will instrument the page properly.
      const page = await target.page();
    }

    if (await target._initializedPromise) {
      this.emit(Events.Browser.TargetCreated, target);
      context.emit(Events.BrowserContext.TargetCreated, target);
    }
  }

  async maximize() {
    // hacky way of maximizing. --start-maximized and windowState:maximized don't work on macs. Check later.
    const [page] = await this.pages();
    const [width, height] = (await page.evaluate('[screen.availWidth, screen.availHeight];')) as [number, number];
    return this.setWindowBounds(width, height);
  }

  async setWindowBounds(width: number, height: number) {
    const window = (await this.connection.send('Browser.getWindowForTarget', {
      // @ts-ignore
      targetId: page._targetId,
    })) as { windowId: number };
    return this.connection.send('Browser.setWindowBounds', {
      windowId: window.windowId,
      bounds: { top: 0, left: 0, width, height },
    });
  }

  async clearSiteData(origin: string) {
    await this.connection.send('Storage.clearDataForOrigin', {
      origin,
      storageTypes: 'all',
    });
  }

  async setProxy(host: string, port: number) {
    try {
      if (typeof port !== 'number') throw new Error('port is not a number');
      let config = {
        mode: 'fixed_servers',
        rules: {
          singleProxy: {
            scheme: 'http',
            host: host,
            port: port,
          },
          bypassList: [],
        },
      };
      const msg = { value: config, scope: 'regular' };
      this.log.debug(`sending request to change proxy`);
      return this.extension.send(`chrome.proxy.settings.set`, msg);
    } catch (err) {
      const setProxyError = `HackiumBrowser.setProxy: ${err.message}`;
      this.log.error(setProxyError);
      throw new Error(setProxyError);
    }
  }

  async clearProxy() {
    this.log.debug(`sending request to clear proxy`);
    return this.extension.send(`chrome.proxy.settings.clear`, {
      scope: 'regular',
    });
  }

  setActivePage(page: HackiumPage) {
    if (!page) {
      this.log.debug(`tried to set active page to invalid page object.`);
      return;
    }
    this.log.debug(`setting active page with URL %o`, page.url());
    this.activePage = page;
    this.emit(HackiumBrowserEmittedEvents.ActivePageChanged, page);
  }

  getActivePage() {
    if (!this.activePage) throw new Error('no active page in browser instance');
    return this.activePage;
  }
}