// Copyright (c) 2019, 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.

import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' show parseFragment;

import 'package:markdown/markdown.dart' as md;

abstract class DocumentationRenderer {
  DocumentationRenderResult render(
    List<md.Node> nodes, {
    required bool processFullDocs,
    required bool sanitizeHtml,
  });
}

class DocumentationRendererHtml implements DocumentationRenderer {
  const DocumentationRendererHtml();

  @override
  DocumentationRenderResult render(
    List<md.Node> nodes, {
    required bool processFullDocs,
    required bool sanitizeHtml,
  }) {
    if (nodes.isEmpty) {
      return DocumentationRenderResult.empty;
    }

    var rawHtml = md.HtmlRenderer().render(nodes);
    var asHtmlFragment = parseFragment(rawHtml);

    for (var pre in asHtmlFragment.querySelectorAll('pre')) {
      if (pre.children.length > 1 && pre.children.first.localName != 'code') {
        continue;
      }

      if (pre.children.isNotEmpty && pre.children.first.localName == 'code') {
        var code = pre.children.first;
        pre.classes
            .addAll(code.classes.where((name) => name.startsWith('language-')));
      }

      var specifiesLanguage = pre.classes.isNotEmpty;
      // Assume the user intended Dart if there are no other classes present.
      if (!specifiesLanguage) pre.classes.add('language-dart');
    }

    if (sanitizeHtml) {
      _sanitize(asHtmlFragment);
    }

    var asHtml = '';

    if (processFullDocs) {
      // `trim` fixes an issue with line ending differences between Mac and
      // Windows.
      asHtml = asHtmlFragment.outerHtml.trim();
    }
    var asOneLiner = asHtmlFragment.children.isEmpty
        ? ''
        : asHtmlFragment.children.first.innerHtml;

    return DocumentationRenderResult(asHtml: asHtml, asOneLiner: asOneLiner);
  }
}

class DocumentationRenderResult {
  static const empty = DocumentationRenderResult(asHtml: '', asOneLiner: '');

  final String asHtml;
  final String asOneLiner;

  const DocumentationRenderResult(
      {required this.asHtml, required this.asOneLiner});
}

bool _allowClassName(String className) =>
    className == 'deprecated' ||
    className.startsWith('language-') ||
    className == 'markdown-alert' ||
    className.startsWith('markdown-alert-');

Iterable<String> _addLinkRel(String uriString) {
  final uri = Uri.tryParse(uriString);
  if (uri != null && uri.host.isNotEmpty) {
    // TODO(jonasfj): Consider allowing non-ugc links for trusted sites.
    return const ['ugc'];
  }
  return const [];
}

void _sanitize(dom.Node node) {
  if (node is dom.Element) {
    final tagName = node.localName!.toUpperCase();
    if (!_allowedElements.contains(tagName)) {
      node.remove();
      return;
    }
    node.attributes.removeWhere((k, v) {
      final attrName = k.toString();
      if (attrName == 'class') {
        node.classes.removeWhere((cn) => !_allowClassName(cn));
        return node.classes.isEmpty;
      }
      return !_isAttributeAllowed(tagName, attrName, v);
    });
    if (tagName == 'A') {
      final href = node.attributes['href'];
      if (href != null) {
        final rels = _addLinkRel(href);
        if (rels.isNotEmpty) {
          node.attributes['rel'] = rels.join(' ');
        }
      }
    }
  }
  if (node.hasChildNodes()) {
    // doing it in reverse order, because we could otherwise skip one, when a
    // node is removed...
    for (var i = node.nodes.length - 1; i >= 0; i--) {
      _sanitize(node.nodes[i]);
    }
  }
}

bool _isAttributeAllowed(String tagName, String attrName, String value) {
  if (_alwaysAllowedAttributes.contains(attrName)) return true;

  // Special validators for special attributes on special tags (href/src/cite)
  final attributeValidators = _elementAttributeValidators[tagName];
  if (attributeValidators == null) {
    return false;
  }

  final validator = attributeValidators[attrName];
  if (validator == null) {
    return false;
  }

  return validator(value);
}

// Inspired by the set of HTML tags allowed in GFM.
final _allowedElements = <String>{
  'H1',
  'H2',
  'H3',
  'H4',
  'H5',
  'H6',
  'H7',
  'H8',
  'BR',
  'B',
  'I',
  'STRONG',
  'EM',
  'A',
  'PRE',
  'CODE',
  'IMG',
  'TT',
  'DIV',
  'INS',
  'DEL',
  'SUP',
  'SUB',
  'P',
  'OL',
  'UL',
  'TABLE',
  'THEAD',
  'TBODY',
  'TFOOT',
  'BLOCKQUOTE',
  'DL',
  'DT',
  'DD',
  'KBD',
  'Q',
  'SAMP',
  'VAR',
  'HR',
  'RUBY',
  'RT',
  'RP',
  'LI',
  'TR',
  'TD',
  'TH',
  'S',
  'STRIKE',
  'SUMMARY',
  'DETAILS',
  'CAPTION',
  'FIGURE',
  'FIGCAPTION',
  'ABBR',
  'BDO',
  'CITE',
  'DFN',
  'MARK',
  'SMALL',
  'SPAN',
  'TIME',
  'WBR',
};

// Inspired by the set of HTML attributes allowed in GFM.
final _alwaysAllowedAttributes = <String>{
  'abbr',
  'accept',
  'accept-charset',
  'accesskey',
  'action',
  'align',
  'alt',
  'aria-describedby',
  'aria-hidden',
  'aria-label',
  'aria-labelledby',
  'axis',
  'border',
  'cellpadding',
  'cellspacing',
  'char',
  'charoff',
  'charset',
  'checked',
  'clear',
  'cols',
  'colspan',
  'color',
  'compact',
  'coords',
  'datetime',
  'dir',
  'disabled',
  'enctype',
  'for',
  'frame',
  'headers',
  'height',
  'hreflang',
  'hspace',
  'ismap',
  'label',
  'lang',
  'maxlength',
  'media',
  'method',
  'multiple',
  'name',
  'nohref',
  'noshade',
  'nowrap',
  'open',
  'prompt',
  'readonly',
  'rel',
  'rev',
  'rows',
  'rowspan',
  'rules',
  'scope',
  'selected',
  'shape',
  'size',
  'span',
  'start',
  'summary',
  'tabindex',
  'target',
  'title',
  'type',
  'usemap',
  'valign',
  'value',
  'vspace',
  'width',
  'itemprop',
};

bool _alwaysAllowed(String _) => true;

bool _validLink(String url) {
  try {
    final uri = Uri.parse(url);
    return uri.isScheme('https') ||
        uri.isScheme('http') ||
        uri.isScheme('mailto') ||
        !uri.hasScheme;
  } on FormatException {
    return false;
  }
}

bool _validUrl(String url) {
  try {
    final uri = Uri.parse(url);
    return uri.isScheme('https') || uri.isScheme('http') || !uri.hasScheme;
  } on FormatException {
    return false;
  }
}

final _citeAttributeValidator = <String, bool Function(String)>{
  'cite': _validUrl,
};

/// Allow the `id` attribute for `h1`, `h2`, ... `h6`.
final _headingAttributeValidator = <String, bool Function(String)>{
  'id': (id) {
    // This id property is generated by package:markdown in:
    // https://github.com/dart-lang/markdown/blob/ecbffa9bf9109d490b9388e9cb1f2bb801aee63c/lib/src/block_syntaxes/block_syntax.dart#L57-L63

    // Cannot contain anything but a-z0-9_-
    if (id.contains(RegExp('[^a-z0-9_-]'))) {
      return false;
    }
    return true;
  },
};

final _elementAttributeValidators =
    <String, Map<String, bool Function(String)>>{
  'A': {
    'href': _validLink,
  },
  'IMG': {
    'src': _validUrl,
    'longdesc': _validUrl,
  },
  'DIV': {
    'itemscope': _alwaysAllowed,
    'itemtype': _alwaysAllowed,
  },
  'BLOCKQUOTE': _citeAttributeValidator,
  'DEL': _citeAttributeValidator,
  'INS': _citeAttributeValidator,
  'Q': _citeAttributeValidator,
  'H1': _headingAttributeValidator,
  'H2': _headingAttributeValidator,
  'H3': _headingAttributeValidator,
  'H4': _headingAttributeValidator,
  'H5': _headingAttributeValidator,
  'H6': _headingAttributeValidator,
  'H7': _headingAttributeValidator,
  'H8': _headingAttributeValidator,
};
