/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 */

import {Command} from './Command';
import {Event} from './Event';
import {Graph} from './Graph';
import {HeaderWriter} from './HeaderWriter';
import {ImplementationWriter} from './ImplementationWriter';
import {Property} from './Property';
import {PropsType, Type} from './Type';
import fs from 'fs';
import yargs from 'yargs';

const custom = require('../src/custom.json');
// $FlowFixMe[cannot-resolve-module] : this isn't a module, just a JSON file.
const standard = require('devtools-protocol/json/js_protocol.json');

type Descriptor = {|
  types: Array<Type>,
  commands: Array<Command>,
  events: Array<Event>,
|};

function mergeDomains(original: any, extra: any) {
  return {...original, domains: original.domains.concat(extra.domains)};
}

const proto = mergeDomains(standard, custom);

function parseDomains(
  domainObjs: Array<any>,
  ignoreExperimental: boolean,
  includeExperimental: Set<string>,
): Descriptor {
  const desc = {
    types: ([]: Array<Type>),
    commands: ([]: Array<Command>),
    events: ([]: Array<Event>),
  };

  for (const obj of domainObjs) {
    const domain = obj.domain;

    for (const typeObj of obj.types || []) {
      const type = Type.create(
        domain,
        typeObj,
        ignoreExperimental,
        includeExperimental,
      );
      if (type) {
        desc.types.push(type);
      }
    }

    for (const commandObj of obj.commands || []) {
      const command = Command.create(
        domain,
        commandObj,
        !includeExperimental.has(`${domain}.${commandObj.name}`) &&
          ignoreExperimental,
        includeExperimental,
      );
      if (command) {
        desc.commands.push(command);
      }
    }

    for (const eventObj of obj.events || []) {
      const event = Event.create(
        domain,
        eventObj,
        ignoreExperimental,
        includeExperimental,
      );
      if (event) {
        desc.events.push(event);
      }
    }
  }

  return desc;
}

function buildGraph(desc: Descriptor): Graph {
  const graph = new Graph();

  const types = desc.types;
  const commands = desc.commands;
  const events = desc.events;

  const maybeAddPropEdges = function (nodeId: string, props: ?Array<Property>) {
    if (props) {
      for (const prop of props) {
        const refId = prop.getRefDebuggerName();
        (prop: Object).recursive = refId && refId === nodeId;
        if (refId && refId !== nodeId) {
          // Don't add edges for recursive properties.
          graph.addEdge(nodeId, refId);
        }
      }
    }
  };

  for (const type of types) {
    graph.addNode(type.getDebuggerName());

    if (type instanceof PropsType) {
      maybeAddPropEdges(type.getDebuggerName(), type.properties);
    }
  }

  for (const command of commands) {
    graph.addNode(command.getDebuggerName());

    maybeAddPropEdges(command.getDebuggerName(), command.parameters);
    maybeAddPropEdges(command.getDebuggerName(), command.returns);
  }

  for (const event of events) {
    graph.addNode(event.getDebuggerName());

    maybeAddPropEdges(event.getDebuggerName(), event.parameters);
  }

  return graph;
}

function parseRoots(desc: Descriptor, rootsPath: ?string): Array<string> {
  const roots = [];

  if (rootsPath) {
    const buf = fs.readFileSync(rootsPath);
    for (let line of buf.toString().split('\n')) {
      line = line.trim();

      // ignore comments and blank lines
      if (!line.match(/\s*#/) && line.length > 0) {
        roots.push(line);
      }
    }
  } else {
    for (const type of desc.types) {
      roots.push(type.getDebuggerName());
    }
    for (const command of desc.commands) {
      roots.push(command.getDebuggerName());
    }
    for (const event of desc.events) {
      roots.push(event.getDebuggerName());
    }
  }

  return roots;
}

// only include types, commands, events that can be reached from the given
// root messages
function filterReachableFromRoots(
  desc: Descriptor,
  graph: Graph,
  roots: Array<string>,
): Descriptor {
  const traversal = graph.traverse(roots);
  const topoSortedIds = traversal.nodes;

  // Types can include other types by value, so they need to be topologically
  // sorted in the header.
  const typeMap: Map<string, Type> = new Map();
  for (const type of desc.types) {
    typeMap.set(type.getDebuggerName(), type);
  }

  const types = [];
  for (const id of topoSortedIds) {
    const type = typeMap.get(id);
    if (type) {
      types.push(type);
    }
  }

  const cycles: Set<string> = new Set();
  for (const cycle of traversal.cycles) {
    const from = typeMap.get(cycle.from);
    const to = typeMap.get(cycle.to);
    if (from && to) {
      cycles.add(from.id + ' > ' + to.id);
    }
  }

  for (const type of types) {
    if (type instanceof PropsType) {
      for (const prop of type.properties) {
        const ref = (prop: Object).$ref;
        if (ref) {
          const cycleKey = `${type.id} > ${ref}`;
          if (cycles.has(cycleKey)) {
            (prop: Object).cyclical = true;
          }
        }
      }
    }
  }

  // Commands and events don't depend on each other, so just emit them in the
  // order we got them from the JSON file.
  const ids = new Set(topoSortedIds);
  const commands = desc.commands.filter(cmd => ids.has(cmd.getDebuggerName()));
  const events = desc.events.filter(event => ids.has(event.getDebuggerName()));

  // Sort commands and events so the code is easier to read. Types have to be
  // topologically sorted as explained above.
  const comparator = (a: Command | Event, b: Command | Event) => {
    const id1 = a.getDebuggerName();
    const id2 = b.getDebuggerName();
    return id1 < id2 ? -1 : id1 > id2 ? 1 : 0;
  };
  commands.sort(comparator);
  events.sort(comparator);

  return {types, commands, events};
}

async function main(): Promise<void> {
  const args = await yargs
    .usage('Usage: msggen <header_path> <cpp_path>')
    .alias('h', 'help')
    .help('h')
    .boolean('e')
    .alias('e', 'ignore-experimental')
    .describe('e', 'ignore experimental commands, props, and types')
    .alias('i', 'include-experimental')
    .describe('i', 'experimental commands to include')
    .alias('r', 'roots')
    .describe('r', 'path to a file listing root types, events, and commands')
    .nargs('r', 1)
    .demandCommand(2, 2).argv;

  const ignoreExperimental = !!args.e;
  const includeExperimental = new Set(
    typeof args.i === 'string' ? args.i.split(',') : [],
  );
  const [headerPath, implPath] = args._;

  const headerStream = fs.createWriteStream(headerPath);
  const implStream = fs.createWriteStream(implPath);

  const desc = parseDomains(
    proto.domains,
    ignoreExperimental,
    includeExperimental,
  );
  const graph = buildGraph(desc);
  const roots = parseRoots(desc, String(args.roots));

  const reachable = filterReachableFromRoots(desc, graph, roots);

  const hw = new HeaderWriter(
    headerStream,
    reachable.types,
    reachable.commands,
    reachable.events,
  );
  hw.write();

  const iw = new ImplementationWriter(
    implStream,
    reachable.types,
    reachable.commands,
    reachable.events,
  );
  iw.write();
}

// $FlowFixMe[unused-promise]
main();
