// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/**
 * Code for reading an HTML API description.
 */
library from.html;

import 'dart:io';

import 'package:analyzer/src/codegen/html.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import 'package:path/path.dart';

import 'api.dart';

const List<String> specialElements = const [
  'domain',
  'feedback',
  'object',
  'refactorings',
  'refactoring',
  'type',
  'types',
  'request',
  'notification',
  'params',
  'result',
  'field',
  'list',
  'map',
  'enum',
  'key',
  'value',
  'options',
  'ref',
  'code',
  'version',
  'union',
  'index'
];

/**
 * Create an [Api] object from an HTML representation such as:
 *
 * <html>
 *   ...
 *   <body>
 *     ... <version>1.0</version> ...
 *     <domain name="...">...</domain> <!-- zero or more -->
 *     <types>...</types>
 *     <refactorings>...</refactorings>
 *   </body>
 * </html>
 *
 * Child elements of <api> can occur in any order.
 */
Api apiFromHtml(dom.Element html) {
  Api api;
  List<String> versions = <String>[];
  List<Domain> domains = <Domain>[];
  Types types = null;
  Refactorings refactorings = null;
  recurse(html, 'api', {
    'domain': (dom.Element element) {
      domains.add(domainFromHtml(element));
    },
    'refactorings': (dom.Element element) {
      refactorings = refactoringsFromHtml(element);
    },
    'types': (dom.Element element) {
      types = typesFromHtml(element);
    },
    'version': (dom.Element element) {
      versions.add(innerText(element));
    },
    'index': (dom.Element element) {
      /* Ignore; generated dynamically. */
    }
  });
  if (versions.length != 1) {
    throw new Exception('The API must contain exactly one <version> element');
  }
  api = new Api(versions[0], domains, types, refactorings, html);
  return api;
}

/**
 * Check that the given [element] has all of the attributes in
 * [requiredAttributes], possibly some of the attributes in
 * [optionalAttributes], and no others.
 */
void checkAttributes(
    dom.Element element, List<String> requiredAttributes, String context,
    {List<String> optionalAttributes: const []}) {
  Set<String> attributesFound = new Set<String>();
  element.attributes.forEach((String name, String value) {
    if (!requiredAttributes.contains(name) &&
        !optionalAttributes.contains(name)) {
      throw new Exception(
          '$context: Unexpected attribute in ${element.localName}: $name');
    }
    attributesFound.add(name);
  });
  for (String expectedAttribute in requiredAttributes) {
    if (!attributesFound.contains(expectedAttribute)) {
      throw new Exception(
          '$context: ${element.localName} must contain attribute $expectedAttribute');
    }
  }
}

/**
 * Check that the given [element] has the given [expectedName].
 */
void checkName(dom.Element element, String expectedName, [String context]) {
  if (element.localName != expectedName) {
    if (context == null) {
      context = element.localName;
    }
    throw new Exception(
        '$context: Expected $expectedName, found ${element.localName}');
  }
}

/**
 * Create a [Domain] object from an HTML representation such as:
 *
 * <domain name="domainName">
 *   <request method="...">...</request> <!-- zero or more -->
 *   <notification event="...">...</notification> <!-- zero or more -->
 * </domain>
 *
 * Child elements can occur in any order.
 */
Domain domainFromHtml(dom.Element html) {
  checkName(html, 'domain');
  String name = html.attributes['name'];
  String context = name ?? 'domain';
  bool experimental = html.attributes['experimental'] == 'true';
  checkAttributes(html, ['name'], context,
      optionalAttributes: ['experimental']);
  List<Request> requests = <Request>[];
  List<Notification> notifications = <Notification>[];
  recurse(html, context, {
    'request': (dom.Element child) {
      requests.add(requestFromHtml(child, context));
    },
    'notification': (dom.Element child) {
      notifications.add(notificationFromHtml(child, context));
    }
  });
  return new Domain(name, requests, notifications, html,
      experimental: experimental);
}

dom.Element getAncestor(dom.Element html, String name, String context) {
  dom.Element ancestor = html.parent;
  while (ancestor != null) {
    if (ancestor.localName == name) {
      return ancestor;
    }
    ancestor = ancestor.parent;
  }
  throw new Exception(
      '$context: <${html.localName}> must be nested within <$name>');
}

/**
 * Create a [Notification] object from an HTML representation such as:
 *
 * <notification event="methodName">
 *   <params>...</params> <!-- optional -->
 * </notification>
 *
 * Note that the event name should not include the domain name.
 *
 * <params> has the same form as <object>, as described in [typeDeclFromHtml].
 *
 * Child elements can occur in any order.
 */
Notification notificationFromHtml(dom.Element html, String context) {
  String domainName = getAncestor(html, 'domain', context).attributes['name'];
  checkName(html, 'notification', context);
  String event = html.attributes['event'];
  context = '$context.${event != null ? event : 'event'}';
  checkAttributes(html, ['event'], context);
  TypeDecl params;
  recurse(html, context, {
    'params': (dom.Element child) {
      params = typeObjectFromHtml(child, '$context.params');
    }
  });
  return new Notification(domainName, event, params, html);
}

/**
 * Create a single of [TypeDecl] corresponding to the type defined inside the
 * given HTML element.
 */
TypeDecl processContentsAsType(dom.Element html, String context) {
  List<TypeDecl> types = processContentsAsTypes(html, context);
  if (types.length != 1) {
    throw new Exception('$context: Exactly one type must be specified');
  }
  return types[0];
}

/**
 * Create a list of [TypeDecl]s corresponding to the types defined inside the
 * given HTML element.  The following forms are supported.
 *
 * To refer to a type declared elsewhere (or a built-in type):
 *
 *   <ref>typeName</ref>
 *
 * For a list: <list>ItemType</list>
 *
 * For a map: <map><key>KeyType</key><value>ValueType</value></map>
 *
 * For a JSON object:
 *
 *   <object>
 *     <field name="...">...</field> <!-- zero or more -->
 *   </object>
 *
 * For an enum:
 *
 *   <enum>
 *     <value>...</value> <!-- zero or more -->
 *   </enum>
 *
 * For a union type:
 *   <union>
 *     TYPE <!-- zero or more -->
 *   </union>
 */
List<TypeDecl> processContentsAsTypes(dom.Element html, String context) {
  List<TypeDecl> types = <TypeDecl>[];
  recurse(html, context, {
    'object': (dom.Element child) {
      types.add(typeObjectFromHtml(child, context));
    },
    'list': (dom.Element child) {
      checkAttributes(child, [], context);
      types.add(new TypeList(processContentsAsType(child, context), child));
    },
    'map': (dom.Element child) {
      checkAttributes(child, [], context);
      TypeDecl keyType;
      TypeDecl valueType;
      recurse(child, context, {
        'key': (dom.Element child) {
          if (keyType != null) {
            throw new Exception('$context: Key type already specified');
          }
          keyType = processContentsAsType(child, '$context.key');
        },
        'value': (dom.Element child) {
          if (valueType != null) {
            throw new Exception('$context: Value type already specified');
          }
          valueType = processContentsAsType(child, '$context.value');
        }
      });
      if (keyType == null) {
        throw new Exception('$context: Key type not specified');
      }
      if (valueType == null) {
        throw new Exception('$context: Value type not specified');
      }
      types.add(new TypeMap(keyType, valueType, child));
    },
    'enum': (dom.Element child) {
      types.add(typeEnumFromHtml(child, context));
    },
    'ref': (dom.Element child) {
      checkAttributes(child, [], context);
      types.add(new TypeReference(innerText(child), child));
    },
    'union': (dom.Element child) {
      checkAttributes(child, ['field'], context);
      String field = child.attributes['field'];
      types.add(
          new TypeUnion(processContentsAsTypes(child, context), field, child));
    }
  });
  return types;
}

/**
 * Read the API description from the file 'spec_input.html'.  [pkgPath] is the
 * path to the current package.
 */
Api readApi(String pkgPath) {
  File htmlFile = new File(join(pkgPath, 'tool', 'spec', 'spec_input.html'));
  String htmlContents = htmlFile.readAsStringSync();
  dom.Document document = parser.parse(htmlContents);
  dom.Element htmlElement = document.children
      .singleWhere((element) => element.localName.toLowerCase() == 'html');
  return apiFromHtml(htmlElement);
}

void recurse(dom.Element parent, String context,
    Map<String, ElementProcessor> elementProcessors) {
  for (String key in elementProcessors.keys) {
    if (!specialElements.contains(key)) {
      throw new Exception('$context: $key is not a special element');
    }
  }
  for (dom.Node node in parent.nodes) {
    if (node is dom.Element) {
      if (elementProcessors.containsKey(node.localName)) {
        elementProcessors[node.localName](node);
      } else if (specialElements.contains(node.localName)) {
        throw new Exception('$context: Unexpected use of <${node.localName}>');
      } else {
        recurse(node, context, elementProcessors);
      }
    }
  }
}

/**
 * Create a [Refactoring] object from an HTML representation such as:
 *
 * <refactoring kind="refactoringKind">
 *   <feedback>...</feedback> <!-- optional -->
 *   <options>...</options> <!-- optional -->
 * </refactoring>
 *
 * <feedback> and <options> have the same form as <object>, as described in
 * [typeDeclFromHtml].
 *
 * Child elements can occur in any order.
 */
Refactoring refactoringFromHtml(dom.Element html) {
  checkName(html, 'refactoring');
  String kind = html.attributes['kind'];
  String context = kind != null ? kind : 'refactoring';
  checkAttributes(html, ['kind'], context);
  TypeDecl feedback;
  TypeDecl options;
  recurse(html, context, {
    'feedback': (dom.Element child) {
      feedback = typeObjectFromHtml(child, '$context.feedback');
    },
    'options': (dom.Element child) {
      options = typeObjectFromHtml(child, '$context.options');
    }
  });
  return new Refactoring(kind, feedback, options, html);
}

/**
 * Create a [Refactorings] object from an HTML representation such as:
 *
 * <refactorings>
 *   <refactoring kind="...">...</refactoring> <!-- zero or more -->
 * </refactorings>
 */
Refactorings refactoringsFromHtml(dom.Element html) {
  checkName(html, 'refactorings');
  String context = 'refactorings';
  checkAttributes(html, [], context);
  List<Refactoring> refactorings = <Refactoring>[];
  recurse(html, context, {
    'refactoring': (dom.Element child) {
      refactorings.add(refactoringFromHtml(child));
    }
  });
  return new Refactorings(refactorings, html);
}

/**
 * Create a [Request] object from an HTML representation such as:
 *
 * <request method="methodName">
 *   <params>...</params> <!-- optional -->
 *   <result>...</result> <!-- optional -->
 * </request>
 *
 * Note that the method name should not include the domain name.
 *
 * <params> and <result> have the same form as <object>, as described in
 * [typeDeclFromHtml].
 *
 * Child elements can occur in any order.
 */
Request requestFromHtml(dom.Element html, String context) {
  String domainName = getAncestor(html, 'domain', context).attributes['name'];
  checkName(html, 'request', context);
  String method = html.attributes['method'];
  context = '$context.${method != null ? method : 'method'}';
  checkAttributes(html, ['method'], context);
  TypeDecl params;
  TypeDecl result;
  recurse(html, context, {
    'params': (dom.Element child) {
      params = typeObjectFromHtml(child, '$context.params');
    },
    'result': (dom.Element child) {
      result = typeObjectFromHtml(child, '$context.result');
    }
  });
  return new Request(domainName, method, params, result, html);
}

/**
 * Create a [TypeDefinition] object from an HTML representation such as:
 *
 * <type name="typeName">
 *   TYPE
 * </type>
 *
 * Where TYPE is any HTML that can be parsed by [typeDeclFromHtml].
 *
 * Child elements can occur in any order.
 */
TypeDefinition typeDefinitionFromHtml(dom.Element html) {
  checkName(html, 'type');
  String name = html.attributes['name'];
  String context = name != null ? name : 'type';
  checkAttributes(html, ['name'], context,
      optionalAttributes: ['experimental']);
  TypeDecl type = processContentsAsType(html, context);
  bool experimental = html.attributes['experimental'] == 'true';
  return new TypeDefinition(name, type, html, experimental: experimental);
}

/**
 * Create a [TypeEnum] from an HTML description.
 */
TypeEnum typeEnumFromHtml(dom.Element html, String context) {
  checkName(html, 'enum', context);
  checkAttributes(html, [], context);
  List<TypeEnumValue> values = <TypeEnumValue>[];
  recurse(html, context, {
    'value': (dom.Element child) {
      values.add(typeEnumValueFromHtml(child, context));
    }
  });
  return new TypeEnum(values, html);
}

/**
 * Create a [TypeEnumValue] from an HTML description such as:
 *
 * <enum>
 *   <code>VALUE</code>
 * </enum>
 *
 * Where VALUE is the text of the enumerated value.
 *
 * Child elements can occur in any order.
 */
TypeEnumValue typeEnumValueFromHtml(dom.Element html, String context) {
  checkName(html, 'value', context);
  checkAttributes(html, [], context);
  List<String> values = <String>[];
  recurse(html, context, {
    'code': (dom.Element child) {
      String text = innerText(child).trim();
      values.add(text);
    }
  });
  if (values.length != 1) {
    throw new Exception('$context: Exactly one value must be specified');
  }
  return new TypeEnumValue(values[0], html);
}

/**
 * Create a [TypeObjectField] from an HTML description such as:
 *
 * <field name="fieldName">
 *   TYPE
 * </field>
 *
 * Where TYPE is any HTML that can be parsed by [typeDeclFromHtml].
 *
 * In addition, the attribute optional="true" may be used to specify that the
 * field is optional, and the attribute value="..." may be used to specify that
 * the field is required to have a certain value.
 *
 * Child elements can occur in any order.
 */
TypeObjectField typeObjectFieldFromHtml(dom.Element html, String context) {
  checkName(html, 'field', context);
  String name = html.attributes['name'];
  context = '$context.${name != null ? name : 'field'}';
  checkAttributes(html, ['name'], context,
      optionalAttributes: ['optional', 'value']);
  bool optional = false;
  String optionalString = html.attributes['optional'];
  if (optionalString != null) {
    switch (optionalString) {
      case 'true':
        optional = true;
        break;
      case 'false':
        optional = false;
        break;
      default:
        throw new Exception(
            '$context: field contains invalid "optional" attribute: "$optionalString"');
    }
  }
  String value = html.attributes['value'];
  TypeDecl type = processContentsAsType(html, context);
  return new TypeObjectField(name, type, html,
      optional: optional, value: value);
}

/**
 * Create a [TypeObject] from an HTML description.
 */
TypeObject typeObjectFromHtml(dom.Element html, String context) {
  checkAttributes(html, [], context, optionalAttributes: ['experimental']);
  List<TypeObjectField> fields = <TypeObjectField>[];
  recurse(html, context, {
    'field': (dom.Element child) {
      fields.add(typeObjectFieldFromHtml(child, context));
    }
  });
  bool experimental = html.attributes['experimental'] == 'true';
  return new TypeObject(fields, html, experimental: experimental);
}

/**
 * Create a [Types] object from an HTML representation such as:
 *
 * <types>
 *   <type name="...">...</type> <!-- zero or more -->
 * </types>
 */
Types typesFromHtml(dom.Element html) {
  checkName(html, 'types');
  String context = 'types';
  checkAttributes(html, [], context);
  Map<String, TypeDefinition> types = <String, TypeDefinition>{};
  recurse(html, context, {
    'type': (dom.Element child) {
      TypeDefinition typeDefinition = typeDefinitionFromHtml(child);
      types[typeDefinition.name] = typeDefinition;
    }
  });
  return new Types(types, html);
}

typedef void ElementProcessor(dom.Element element);

typedef void TextProcessor(dom.Text text);
