import 'dart:math';

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:html/parser.dart';
import 'package:html/dom.dart' as dom;
import 'package:latlong2/latlong.dart';
import 'package:logger/logger.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/util/widgets/warning.dart';
import 'package:html_unescape/html_unescape_small.dart';
import 'package:voyagehandbook/views/pageview.dart';
import 'package:widget_zoom/widget_zoom.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

/*
        Voyage Handbook - The open-source WikiVoyage reader
        Copyright (C) 2023 Matyáš Caras

        This program is free software: you can redistribute it and/or modify
        it under the terms of the GNU General Public License version 3 as published by
        the Free Software Foundation.

        This program is distributed in the hope that it will be useful,
        but WITHOUT ANY WARRANTY; without even the implied warranty of
        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
        GNU General Public License for more details.

        You should have received a copy of the GNU General Public License
        along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/
final _ignoredTags = ["style", "script"];
final logger = Logger(printer: PrettyPrinter());

class PageRenderer {
  final ColorScheme scheme;
  final double height;
  final double width;
  final BuildContext context;
  final AppLocalizations loc;

  /// For offline downloads; don't bother rendering the widget tree
  final bool offline;

  /// HTML for offline download / caching
  String outHtml = "";
  String? document;

  PageRenderer(this.scheme, this.height, this.width, this.context, this.loc,
      {this.offline = false});

  /// Used to create Widgets from raw HTML from WM API
  Future<ListView> renderFromPageHTML(RawPage page) async {
    var out = <Widget>[
      const SizedBox(
        height: 10,
      ),
      SelectableText(
        page.title,
        textAlign: TextAlign.center,
        style: PageStyles.h1,
      ),
      const SizedBox(
        height: 10,
      ),
      Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          SelectableText(loc.attrFrom),
          const SizedBox(
            width: 5,
          ),
          SelectableText(
            "WikiVoyage",
            onTap: () => launchUrl(
                Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"),
                mode: LaunchMode.externalApplication),
            style: const TextStyle(
              decoration: TextDecoration.underline,
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
      Row(
        children: [
          SelectableText(loc.attrUnder),
          const SizedBox(
            width: 2,
          ),
          Flexible(
            child: SelectableText(
              page.license.title,
              textAlign: TextAlign.center,
              onTap: () => launchUrl(Uri.parse(page.license.url),
                  mode: LaunchMode.externalApplication),
              style: const TextStyle(
                decoration: TextDecoration.underline,
              ),
            ),
          )
        ],
      )
    ];
    outHtml = "<html><head></head><body>";
    var document = parse(page.html);
    var sections = document.body!.getElementsByTagName("section");
    this.document = page.html;
    for (var sec in sections) {
      if (sec.localName == "section") {
        if (!offline) out.addAll(await _renderSection(sec));
        outHtml += sec.outerHtml;
      }
    }
    var l = ListView.builder(
      padding: EdgeInsets.zero,
      itemBuilder: (c, i) => out[i],
      itemCount: out.length,
    );
    return l;
  }

  Future<List<Widget>> _renderSection(dom.Element sec) async {
    var out = <Widget>[];
    // Get Section Title
    var headings = sec.children.where(
        (element) => ["h2", "h3", "h4", "h5"].contains(element.localName));
    var sectionTitle = (headings.isNotEmpty) ? headings.first : null;
    if (sectionTitle != null) {
      switch (sectionTitle.localName) {
        case "h2":
          out.add(
            const SizedBox(
              height: 10,
            ),
          );
          out.add(SelectableText(
            sectionTitle.text,
            style: PageStyles.h2,
            textAlign: TextAlign.center,
          ));
          break;
        case "h3":
          out.add(
            Align(
              alignment: Alignment.centerLeft,
              child: SelectableText(
                sectionTitle.text,
                style: PageStyles.h3,
              ),
            ),
          );
          break;
        case "h4":
          out.add(
            Align(
              alignment: Alignment.centerLeft,
              child: SelectableText(sectionTitle.text, style: PageStyles.h4),
            ),
          );
          break;
        case "h5":
          out.add(
            Align(
              alignment: Alignment.centerLeft,
              child: SelectableText(sectionTitle.text, style: PageStyles.h5),
            ),
          );
          break;
        default:
          out.add(
            Align(
              alignment: Alignment.centerLeft,
              child: SelectableText(sectionTitle.text),
            ),
          );
          break;
      }
      // out.add(
      //   const SizedBox(
      //     height: 5,
      //   ),
      // );
    }

    // create TextSpans from text content
    for (var element in sec.children) {
      // Go through all section's children
      for (var t in _ignoredTags) {
        var ignored = element.getElementsByTagName(t);
        if (ignored.isNotEmpty) {
          // Remove ignored tags
          for (var element in ignored) {
            element.remove();
          }
        }
      }

      switch (element.localName) {
        case "p":
        case "link":
          if (element
              .getElementsByClassName("mw-kartographer-maplink")
              .isNotEmpty) break;
          out.add(
            SelectableText.rich(
              TextSpan(
                children: _renderText(element.innerHtml),
              ),
              style: const TextStyle(height: 1.2),
              textAlign: TextAlign.justify,
            ),
          ); // add paragraph spans as single rich text
          out.add(
            const SizedBox(
              height: 5,
            ),
          ); // space paragraphs
          break;
        case "figure":
          out.addAll(await _renderImageFigure(element));
          out.add(
            const SizedBox(
              height: 10,
            ),
          );
          break;
        case "section":
          out.addAll(await _renderSection(element));
          break;
        case "dl":
          var dd = element.getElementsByTagName("dd").first;
          var link = RegExp(r'<a .+? href="\.\/(.+?)".+?>(.+?)<', dotAll: true)
              .firstMatch(dd.innerHtml);
          if (link == null) {
            break; // TODO: handle `dd` which are not "see also" links
          }
          out.add(
            Row(
              children: [
                const SelectableText(
                  "See also:",
                  style: TextStyle(),
                ),
                const SizedBox(
                  width: 5,
                ),
                SelectableText(
                  link.group(2)!,
                  onTap: () => Navigator.of(context).push(
                    MaterialPageRoute(
                      builder: (_) => ArticleView(
                          pageKey: link.group(1)!, name: link.group(2)!),
                    ),
                  ),
                  style: const TextStyle(
                      fontStyle: FontStyle.italic,
                      decoration: TextDecoration.underline),
                )
              ],
            ),
          );
          out.add(
            const SizedBox(
              height: 5,
            ),
          );
          break;
        case "ul":
          out.add(_renderList(element));
          out.add(
            const SizedBox(
              height: 10,
            ),
          );
          break;
        case "div":
          if (element.attributes["class"] != null &&
              element.attributes["class"] == "pp_cautionbox") {
            out.add(_renderWarning(element));
            out.add(
              const SizedBox(
                height: 10,
              ),
            );
          } else if (element.id == "region_list") {
            var inner = parse(
              element.innerHtml.replaceAll(RegExp(r'<\/?span.+?>'), ""),
            );
            for (var e in inner.body!.children) {
              if (e.localName == "figure") {
                // render image
                out.addAll(await _renderImageFigure(e));
                out.add(
                  const SizedBox(
                    height: 5,
                  ),
                );
              } else if (e.localName == "table") {
                Color? boxColor;
                var text = <InlineSpan>[];
                for (var td in e
                    .getElementsByTagName("tr")
                    .first
                    .getElementsByTagName("td")) {
                  if (td.attributes["style"] != null) {
                    var colorMatch = RegExp(r'background-color:#(.+?);')
                        .firstMatch(td.attributes["style"]!);
                    boxColor = Color(int.parse("0xff${colorMatch!.group(1)}"));
                  } else {
                    text.addAll(_renderText(td.innerHtml));
                  }
                }
                out.add(
                  const SizedBox(
                    height: 10,
                  ),
                );
                out.add(
                  Row(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      if (boxColor != null)
                        Container(
                          color: boxColor,
                          width: 25,
                          height: 25,
                        ),
                      const SizedBox(
                        width: 10,
                      ),
                      Flexible(
                        child: SelectableText.rich(
                          TextSpan(children: text),
                        ),
                      )
                    ],
                  ),
                );
              }
            }
            out.add(
              const SizedBox(
                height: 10,
              ),
            );
          } else if (element.classes.contains("mw-kartographer-container")) {
            logger.i("Found map container");
            var imgs = element
                .getElementsByTagName("div")
                .first
                .getElementsByTagName("a")
                .first
                .getElementsByTagName("img");
            if (imgs.isEmpty ||
                (imgs.first.attributes["src"]
                        ?.startsWith("https://maps.wikimedia.org") ??
                    false)) {
              logger.i("Rendering with FlutterMap");
              // render map using FlutterMap
              var dataElement = element
                  .getElementsByClassName("thumbinner")
                  .first
                  .getElementsByClassName("mw-kartographer-map")
                  .first;

              var pointsRaw = RegExp(
                      r"""<span class="noprint listing-coordinates".+?><span class="geo"><abbr class="latitude">(.+?)<\/abbr><abbr class="longitude">(.+?)<\/abbr>.+?}'>(\d+)<""")
                  .allMatches(document!); // find markers
              logger.i("Found ${pointsRaw.length} markers");
              if (pointsRaw.isEmpty) break;
              var points = <Marker>[];
              for (var point in pointsRaw) {
                // convert to FlutterMap markers
                if (point.groups([1, 2]).any((element) => element == null)) {
                  logger.w("No lat/lon found in pointer");
                  continue;
                }
                // assign marker color
                var colorMatch = RegExp(r'background: #(.+?);')
                    .firstMatch(point.group(0)!)
                    ?.group(1);
                Color bubbleColor = (colorMatch == null)
                    ? scheme.secondary
                    : Color(int.parse("0xff$colorMatch"));

                points.add(
                  Marker(
                    builder: (c) => Container(
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(4),
                          color: bubbleColor),
                      child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Text(point.group(3) ?? "X"),
                      ),
                    ),
                    point: LatLng(
                      double.parse(point.group(1)!),
                      double.parse(point.group(2)!),
                    ),
                  ),
                );
              }
              out.add(const SizedBox(
                height: 10,
              ));
              out.add(
                SizedBox(
                  width: MediaQuery.of(context).size.width * 0.8,
                  height: (MediaQuery.of(context).size.width * 0.8 / 4) * 3,
                  child: FlutterMap(
                    options: MapOptions(
                      center: LatLng(
                        double.parse(
                            dataElement.attributes["data-lat"] ?? "00.0"),
                        double.parse(
                            dataElement.attributes["data-lon"] ?? "00.0"),
                      ),
                      zoom: double.parse(
                          dataElement.attributes["data-zoom"] ?? "1"),
                    ),
                    nonRotatedChildren: [
                      RichAttributionWidget(
                        attributions: [
                          TextSourceAttribution(
                            "OpenStreetMap contributors",
                            onTap: () => launchUrlString(
                                "https://openstreetmap.org/copyright"),
                          )
                        ],
                      )
                    ],
                    children: [
                      TileLayer(
                        urlTemplate:
                            "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
                        userAgentPackageName: "cafe.caras.voyagehandbook",
                      ),
                      MarkerLayer(
                        markers: points,
                      )
                    ],
                  ),
                ),
              );
            } else {
              var img = imgs[0];
              var cap = element.getElementsByClassName("thumbcaption")[0];
              out.add(const SizedBox(
                height: 10,
              ));
              var offlineImage = await StorageAccess.getOfflineImage(img
                  .attributes["src"]!
                  .split('/')
                  .last
                  .replaceAll(RegExp(r"(?!\..+?)\?.+"), ""));
              out.add(
                SizedBox(
                  width: width * 0.8,
                  height: height * 0.3,
                  child: WidgetZoom(
                    zoomWidget:
                        CachedNetworkImage(imageUrl: img.attributes["src"]!),
                    heroAnimationTag: 'tag',
                  ),
                ),
              );
              out.add(
                const SizedBox(
                  height: 3,
                ),
              );
              out.add(
                SelectableText(
                  cap.text,
                  textAlign: TextAlign.center,
                ),
              );
              out.add(
                const SizedBox(
                  height: 10,
                ),
              );
            }
          }
          break;
        default:
          break;
      }

      element.remove();
    }

    out.add(
      const SizedBox(
        height: 5,
      ),
    );
    return out;
  }

  Future<List<Widget>> _renderImageFigure(dom.Element element) async {
    var out = <Widget>[];

    /// Image figure
    var imgs = element.getElementsByTagName("img");
    if (imgs.isEmpty) return [];
    var img = imgs.first; // get image element
    if (img.attributes["src"] == null || img.attributes["src"] == "") return [];
    var figcap = element.getElementsByTagName("figcaption"); // get caption
    String? caption;
    if (figcap.isNotEmpty) {
      caption = figcap.first.text; // TODO: handle links
    }
    var offlineImage = await StorageAccess.getOfflineImage(img
        .attributes["src"]!
        .split('/')
        .last
        .replaceAll(RegExp(r"(?!\..+?)\?.+"), ""));

    out.add(const SizedBox(
      height: 10,
    ));
    out.add(
      SizedBox(
        // TODO: open wikimedia page?
        width: width * 0.8,
        height: height * 0.3,
        child: WidgetZoom(
          heroAnimationTag: 'img${Random().nextInt(999)}',
          zoomWidget: CachedNetworkImage(
            imageUrl: img.attributes["src"]!.replaceAll("//", "https://"),
            progressIndicatorBuilder: (context, url, downloadProgress) =>
                LinearProgressIndicator(value: downloadProgress.progress),
            errorWidget: (context, url, error) => (offlineImage != null)
                ? Image.file(offlineImage)
                : Flexible(
                    child: Text(loc.imageError),
                  ),
          ),
        ),
      ),
    ); // load image
    if (caption != null) {
      // Add caption when available
      out.add(
        const SizedBox(
          height: 3,
        ),
      );
      out.add(
        SelectableText(
          caption,
          textAlign: TextAlign.center,
        ),
      );
    }

    return out;
  }

// used to render warning box
  Widget _renderWarning(dom.Element element) {
    var content = <InlineSpan>[];
    for (var tr in element
        .getElementsByTagName("table")
        .first
        .getElementsByTagName("tbody")
        .first
        .getElementsByTagName("tr")) {
      for (var e in tr.getElementsByTagName("td")) {
        // Get to table data
        content.addAll(
          _renderText(
            e.innerHtml.replaceAll(RegExp(r'<img.+?\/?>'), ""),
          ),
        );
      }
    }
    return Warning(content: content);
  }

  /// Used to render basic text with links, bold and italic text
  List<InlineSpan> _renderText(String innerHtml) {
    var unescape = HtmlUnescape();
    innerHtml = unescape.convert(innerHtml);
    var content = <InlineSpan>[];
    var input = innerHtml
        .replaceAll(
            RegExp(r"<(?!(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).+?>",
                dotAll: true),
            "")
        .replaceAll("\\n", "\n")
        .replaceAll("<br>", "\n");

    // first find links
    var linkMatch = RegExp(
            r'(?:<(?:i|b).*?>)*<a .+? href="(.+?)" .+?>(.+?)<\/a>(?:\/<(?:i|b)>)*',
            dotAll: true)
        .allMatches(input)
        .toList();
    var nonLink = input.split(RegExp(
        r'(?:<(?:i|b).*?>)*<a .+? href="(.+?)" .+?>(.+?)<\/a>(?:\/<(?:i|b)>)*',
        dotAll: true));

    for (var match in linkMatch) {
      // format text before link first
      content.addAll(_formatText(nonLink[linkMatch.indexOf(match)]));

      // create link
      content.add(
        WidgetSpan(
          child: SelectableText(
            match.group(2)!.replaceAll(
                RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
                ""),
            onTap: () {
              if (match.group(0)!.contains('rel="mw:ExtLink')) {
                // handle as an external link
                launchUrlString(match.group(1)!,
                    mode: LaunchMode.externalApplication);
              } else {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => ArticleView(
                        pageKey: match.group(1)!.replaceAll("./", ""),
                        name: match.group(2)!),
                  ),
                );
              }
            },
            style: TextStyle(
                decoration: TextDecoration.underline,
                fontWeight: (match.group(0)!.contains("<b") ||
                        match.group(0)!.contains("</b>"))
                    ? FontWeight.bold
                    : null,
                fontStyle: (match.group(0)!.contains("<i") ||
                        match.group(0)!.contains("</i>"))
                    ? FontStyle.italic
                    : null),
          ),
        ),
      );
    }

    content.addAll(_formatText(nonLink.last)); // add last
    return content;
  }

  /// Formats text (bold, italics)
  List<InlineSpan> _formatText(String input) {
    var content = <InlineSpan>[];
    var noFormatting =
        input.split(RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true));
    var needToFormat =
        RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true)
            .allMatches(input)
            .toList();
    for (var s in needToFormat) {
      content.add(TextSpan(
          text: noFormatting[needToFormat.indexOf(s)].replaceAll(
              RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
              ""))); // add text before styled
      var raw = s.group(0)!;
      content.add(
        TextSpan(
          text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1)!.replaceAll(
              RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
              ""), // replace stray tags
          style: TextStyle(
            fontWeight: (raw.contains("<b") || raw.contains("</b>"))
                ? FontWeight.bold
                : null,
            fontStyle: (raw.contains("<i") || raw.contains("</i>"))
                ? FontStyle.italic
                : null,
          ),
        ),
      ); // add styled
    }
    content.add(TextSpan(
        text: noFormatting.last.replaceAll(
            RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
            ""))); // add last
    return content;
  }

  SingleChildScrollView _renderList(dom.Element element) {
    var out = <Widget>[];
    var i = 0;
    for (var item in element.getElementsByTagName("li")) {
      i++;
      String? title = (item.getElementsByClassName("listing-name").isNotEmpty)
          ? item.getElementsByClassName("listing-name")[0].text
          : null;

      var colorMatch =
          RegExp(r'background: #(.+?);').firstMatch(item.innerHtml)?.group(1);
      Color bubbleColor = (colorMatch == null)
          ? scheme.secondary
          : Color(int.parse("0xff$colorMatch"));
      if (element.getElementsByClassName("geo").isNotEmpty) {
        element.getElementsByClassName("geo").first.remove();
      }
      var rest =
          ((title != null) ? item.text.replaceAll(title, "") : item.text);
      if (rest.startsWith("1")) {
        rest = rest.substring(1); // TODO: figure out how to remove it better?
      }
      out.add(const SizedBox(
        height: 5,
      ));
      out.add(
        Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Container(
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(8), color: bubbleColor),
              width: 30,
              height: 30,
              child: SelectableText(
                i.toString(),
                style: TextStyle(
                    color: scheme.background,
                    fontWeight: FontWeight.bold,
                    fontSize: 17),
                textAlign: TextAlign.center,
              ),
            ),
            const SizedBox(
              width: 10,
            ),
            SelectableText.rich(
              TextSpan(
                children: [
                  if (title != null)
                    TextSpan(
                      text: title,
                      style: const TextStyle(fontWeight: FontWeight.bold),
                    ),
                  TextSpan(text: rest)
                ],
              ),
            )
          ],
        ),
      );
    }
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisAlignment: MainAxisAlignment.start,
        children: out,
      ),
    );
  }
}
