const fieldKind = {
  SCALAR: Symbol('scalar'),
  LINKED: Symbol('linked'),
};

class Spec {
  getRequestedFields(_kind) {
    return [];
  }

  // Return the names of all known scalar (Int, String, and so forth) fields selected by current queries.
  getRequestedScalarFields() {
    return this.getRequestedFields(fieldKind.SCALAR);
  }

  // Return the names of all known linked (composite with sub-field) fields selected by current queries.
  getRequestedLinkedFields() {
    return this.getRequestedFields(fieldKind.LINKED);
  }

  getLinkedSpecCreator(_name) {
    throw new Error('No linked specs');
  }
}

// Wraps one or more GraphQL query specs loaded from code generated by relay-compiler. These are the files with
// names matching `**/__generated__/*.graphql.js` that are created when you run "npm run relay".
export class FragmentSpec extends Spec {

  // Normalize an Array of "node" objects imported from *.graphql.js files. Wrap them in a Spec instance to take
  // advantage of query methods.
  constructor(nodes, typeNameSet) {
    super();

    const gettersByKind = {
      Fragment: node => node,
      LinkedField: node => node,
      Request: node => node.fragment,
    };

    this.nodes = nodes.map(node => {
      const fn = gettersByKind[node.kind];
      if (fn === undefined) {
        /* eslint-disable-next-line */
        console.error(
          `Unrecognized node kind "${node.kind}".\n` +
          "I couldn't figure out what to do with a parsed GraphQL module.\n" +
          "Either you're passing something unexpected into an xyzBuilder() method,\n" +
          'or the Relay compiler is generating something that our builder factory\n' +
          "doesn't yet know how to handle.",
          node,
        );
        throw new Error(`Unrecognized node kind ${node.kind}`);
      } else {
        return fn({...node});
      }
    });

    // Discover and (recursively) flatten any inline fragment spreads in-place.
    const flattenFragments = selections => {
      const spreads = new Map();
      for (let i = selections.length - 1; i >= 0; i--) {
        if (selections[i].kind === 'InlineFragment') {
          // Replace inline fragments in-place with their selected fields *if* the GraphQL type name matches.
          if (!typeNameSet.has(selections[i].type)) {
            continue;
          }
          spreads.set(i, selections[i].selections);
        }
      }

      for (const [index, subSelections] of spreads) {
        flattenFragments(subSelections);
        selections.splice(index, 1, ...subSelections);
      }
    };

    for (const node of this.nodes) {
      node.selections = node.selections.slice();
      flattenFragments(node.selections);
    }
  }

  // Query all of our query specs for the names of selected fields that match a certain "kind". Field kinds include
  // ScalarField, LinkedField, FragmentSpread, and likely others. Field aliases are preferred over field names if
  // present. Fields that are duplicated across query specs (which could happen when multiple query specs are
  // provided) will be returned once.
  getRequestedFields(kind) {
    let kindName = null;
    if (kind === fieldKind.SCALAR) {
      kindName = 'ScalarField';
    }
    if (kind === fieldKind.LINKED) {
      kindName = 'LinkedField';
    }

    const fieldNames = new Set();
    for (const node of this.nodes) {
      for (const selection of node.selections) {
        if (selection.kind === kindName) {
          fieldNames.add(selection.alias || selection.name);
        }
      }
    }
    return Array.from(fieldNames);
  }

  // Return one or more subqueries that describe fields selected within a linked field called "name". If no such
  // subqueries may be found, an error is thrown.
  getLinkedSpecCreator(name) {
    const subNodes = [];
    for (const node of this.nodes) {
      const match = node.selections.find(selection => selection.alias === name || selection.name === name);
      if (match) {
        subNodes.push(match);
      }
    }
    if (subNodes.length === 0) {
      throw new Error(`Unable to find linked field ${name}`);
    }
    return typeNameSet => new FragmentSpec(subNodes, typeNameSet);
  }
}

export class QuerySpec extends Spec {
  constructor(root, typeNameSet, fragmentsByName) {
    super();

    this.root = root;
    this.fragmentsByName = fragmentsByName;

    // Discover and (recursively) flatten any fragment spreads in-place.
    const flattenFragments = selections => {
      const spreads = new Map();
      for (let i = selections.length - 1; i >= 0; i--) {
        if (selections[i].kind === 'InlineFragment') {
          // Replace inline fragments in-place with their selected fields *if* the GraphQL type name matches.

          if (selections[i].typeCondition.kind !== 'NamedType' || !typeNameSet.has(selections[i].typeCondition.name.value)) {
            continue;
          }
          spreads.set(i, selections[i].selectionSet.selections);
        } else if (selections[i].kind === 'FragmentSpread') {
          // Replace named fragments in-place with their selected fields if a non-null SpecRegistry is available and
          // the GraphQL type name matches.

          const fragment = this.fragmentsByName.get(selections[i].name.value);
          if (!fragment) {
            throw new Error(`Reference to unknown fragment: ${selections[i].name.value}`);
          }
          if (fragment.typeCondition.kind !== 'NamedType' || !typeNameSet.has(fragment.typeCondition.name.value)) {
            continue;
          }

          spreads.set(i, fragment.selectionSet.selections);
        }
      }

      for (const [index, subSelections] of spreads) {
        flattenFragments(subSelections);
        selections.splice(index, 1, ...subSelections);
      }
    };

    flattenFragments(this.root.selectionSet.selections);
  }

  getRequestedFields(kind) {
    const fields = [];
    for (const selection of this.root.selectionSet.selections) {
      if (selection.kind !== 'Field') {
        continue;
      }

      if (selection.selectionSet === undefined && kind !== fieldKind.SCALAR) {
        continue;
      } else if (selection.selectionSet !== undefined && kind !== fieldKind.LINKED) {
        continue;
      }

      fields.push((selection.alias || selection.name).value);
    }
    return fields;
  }

  getLinkedSpecCreator(name) {
    for (const selection of this.root.selectionSet.selections) {
      if (selection.kind !== 'Field' || selection.selectionSet === undefined) {
        continue;
      }

      const fieldName = (selection.alias || selection.name).value;
      if (fieldName === name) {
        return typeNameSet => new QuerySpec(selection, typeNameSet, this.fragmentsByName);
      }
    }

    throw new Error(`Unable to find linked field ${name}`);
  }
}
