(function attachVideoSkipClient(root) {
  "use strict";

  var Common = root.SafeSceneCommon || require("./common.js");

  function VideoSkipClient(fetchImpl, options) {
    this.fetchImpl = fetchImpl;
    this.baseUrl = options && options.baseUrl ? options.baseUrl : "https://videoskip.herokuapp.com";
    this.cacheTtlMs = options && options.cacheTtlMs ? options.cacheTtlMs : 6 * 60 * 60 * 1000;
    this.cache = new Map();
  }

  VideoSkipClient.prototype.getCached = async function getCached(key, loader) {
    var existing = this.cache.get(key);
    var now = Date.now();

    if (existing && existing.expiresAt > now) {
      return existing.value;
    }

    var value = await loader();
    this.cache.set(key, {
      value: value,
      expiresAt: now + this.cacheTtlMs
    });
    return value;
  };

  VideoSkipClient.prototype.fetchText = async function fetchText(relativeUrl) {
    var self = this;
    return this.getCached(relativeUrl, async function loadText() {
      var response = await self.fetchImpl(self.baseUrl + relativeUrl, {
        headers: {
          "accept": "text/html,application/xhtml+xml"
        }
      });

      if (!response.ok) {
        throw new Error("VideoSkip request failed: " + response.status + " " + relativeUrl);
      }

      return response.text();
    });
  };

  function escapeRegExp(value) {
    return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  }

  function decodeHtmlEntities(value) {
    return String(value || "")
      .replace(/&nbsp;/g, " ")
      .replace(/&#39;/g, "'")
      .replace(/&#x27;/g, "'")
      .replace(/&quot;/g, "\"")
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&#x2F;/g, "/");
  }

  function stripTags(value) {
    return decodeHtmlEntities(String(value || "").replace(/<[^>]*>/g, " "));
  }

  function normalizeWhitespace(value) {
    return stripTags(value).replace(/\s+/g, " ").trim();
  }

  function parseSeriesMatches(html) {
    var regex = /<a href="\/exchange\/series\/(\d+)\/">([\s\S]*?)<\/a>/g;
    var matches = [];
    var match = null;

    while ((match = regex.exec(String(html || "")))) {
      matches.push({
        seriesId: match[1],
        title: normalizeWhitespace(match[2])
      });
    }

    return matches;
  }

  function parseMovieVideoMatches(html) {
    var regex = /<a href="\/exchange\/videos\/(\d+)\/">([\s\S]*?)<\/a>\s*\((\d{4})\)/g;
    var matches = [];
    var match = null;

    while ((match = regex.exec(String(html || "")))) {
      matches.push({
        videoId: match[1],
        title: normalizeWhitespace(match[2]),
        year: Number.parseInt(match[3], 10)
      });
    }

    return matches;
  }

  function pickBestTitleMatch(matches, showTitle) {
    var best = null;
    var bestScore = -Infinity;

    matches.forEach(function scoreMatch(match) {
      var score = Common.scoreTextSimilarity(match.title, showTitle);
      if (score > bestScore) {
        bestScore = score;
        best = match;
      }
    });

    return bestScore >= 60 ? best : null;
  }

  function parseEpisodeLinks(html) {
    var regex = /<a href="\/exchange\/videos\/(\d+)\/">S(\d+)\s+E(\d+):\s*([\s\S]*?)<\/a>/g;
    var episodes = [];
    var match = null;

    while ((match = regex.exec(String(html || "")))) {
      episodes.push({
        videoId: match[1],
        seasonNumber: Number.parseInt(match[2], 10),
        episodeNumber: Number.parseInt(match[3], 10),
        episodeTitle: normalizeWhitespace(match[4])
      });
    }

    return episodes;
  }

  function parseSkipFileEntries(html) {
    var rows = String(html || "").match(/<tr>[\s\S]*?<\/tr>/g) || [];
    return rows.map(function mapRow(rowHtml) {
      var skipIdMatch = rowHtml.match(/href="\/exchange\/skip\/(\d+)\/"/);
      var downloadsMatch = rowHtml.match(/<em>(\d+)\s+downloads<\/em>/i);
      var filtersMatch = rowHtml.match(/<strong>Filters:\s*<\/strong>([\s\S]*?)<\/p>/i);
      var syncedWithMatch = rowHtml.match(/Synced with:\s*([^<]+)/i);

      return {
        skipId: skipIdMatch ? skipIdMatch[1] : null,
        downloads: downloadsMatch ? Number.parseInt(downloadsMatch[1], 10) : 0,
        filtersText: filtersMatch ? normalizeWhitespace(filtersMatch[1]) : "",
        syncedWith: syncedWithMatch ? normalizeWhitespace(syncedWithMatch[1]) : "",
        sourceUrl: skipIdMatch ? "/exchange/skip/" + skipIdMatch[1] + "/" : null
      };
    }).filter(function filterValid(entry) {
      return Boolean(entry.skipId);
    });
  }

  function parseSkipFileBody(html) {
    var bodyMatch = String(html || "").match(/<p style="white-space:\s*pre-wrap;">([\s\S]*?)<\/p>/i);
    if (!bodyMatch) {
      return "";
    }

    return decodeHtmlEntities(bodyMatch[1])
      .replace(/<br\s*\/?>/gi, "\n")
      .replace(/\r\n?/g, "\n")
      .trim();
  }

  function parseSourceTitle(html) {
    var match = String(html || "").match(/<h1>\s*Skip file for\s*([\s\S]*?)<\/h1>/i);
    return match ? normalizeWhitespace(match[1]) : "";
  }

  function mapCategoryToken(token) {
    var normalized = Common.normalizeText(token);

    if (normalized.indexOf("sex") === 0) {
      return "sex";
    }

    if (normalized.indexOf("nud") === 0 || normalized.indexOf("nude") === 0) {
      return "nudity";
    }

    if (normalized.indexOf("viol") === 0 || normalized.indexOf("gore") === 0) {
      return "gore";
    }

    return null;
  }

  function parseRangesFromSkipText(skipText, sourceUrl, skipId) {
    var lines = String(skipText || "")
      .split(/\n+/)
      .map(function mapLine(line) { return line.trim(); })
      .filter(Boolean);
    var ranges = [];

    for (var index = 0; index < lines.length - 1; index += 2) {
      var timingLine = lines[index];
      var labelLine = lines[index + 1];
      var timingMatch = timingLine.match(/(\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s*-->\s*(\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)/i);

      if (!timingMatch) {
        continue;
      }

      var startSeconds = Common.parseTimecodeToSeconds(timingMatch[1]);
      var endSeconds = Common.parseTimecodeToSeconds(timingMatch[2]);
      var tokens = labelLine.split(/\s+/).filter(Boolean);
      var category = tokens.length ? mapCategoryToken(tokens[0]) : null;

      if (!category || !Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
        continue;
      }

      ranges.push({
        id: "videoskip-" + skipId + "-" + (ranges.length + 1),
        category: category,
        start: startSeconds,
        end: endSeconds,
        startSeconds: startSeconds,
        endSeconds: endSeconds,
        confidence: "public-skip-file",
        notes: labelLine,
        sourceName: "VideoSkip",
        sourceUrl: sourceUrl
      });
    }

    return ranges;
  }

  function pickBestSkipFile(skipFiles, categorySelection) {
    var desiredTokens = [];
    if (categorySelection.skipNudity) {
      desiredTokens.push("sex and nudity", "nudity", "sex");
    }
    if (categorySelection.skipSex) {
      desiredTokens.push("sex and nudity", "sex");
    }
    if (categorySelection.skipGore) {
      desiredTokens.push("violence and gore", "gore", "violence");
    }

    function scoreEntry(entry) {
      var filters = Common.normalizeText(entry.filtersText);
      var score = 0;

      desiredTokens.forEach(function addDesiredScore(token) {
        if (filters.indexOf(Common.normalizeText(token)) !== -1) {
          score += token === "sex and nudity" || token === "violence and gore" ? 40 : 18;
        }
      });

      score += Math.min(Number(entry.downloads || 0), 500);
      return score;
    }

    return skipFiles
      .map(function mapEntry(entry) {
        return {
          entry: entry,
          score: scoreEntry(entry)
        };
      })
      .sort(function sortEntries(left, right) {
        return right.score - left.score;
      })[0];
  }

  function filterRangesByCategory(ranges, categorySelection) {
    return ranges.filter(function filterRange(range) {
      if (range.category === "nudity") {
        return categorySelection.skipNudity;
      }
      if (range.category === "sex") {
        return categorySelection.skipSex || categorySelection.skipNudity;
      }
      if (range.category === "gore") {
        return categorySelection.skipGore;
      }
      return false;
    });
  }

  VideoSkipClient.prototype.lookupEpisode = async function lookupEpisode(options) {
    var showTitle = String(options && options.showTitle || "").trim();
    var aliases = Array.isArray(options && options.aliases) ? options.aliases : [];
    var seasonNumber = Number(options && options.seasonNumber);
    var episodeNumber = Number(options && options.episodeNumber);
    var categorySelection = Object.assign({
      skipNudity: true,
      skipSex: true,
      skipGore: false
    }, options && options.categorySelection ? options.categorySelection : {});
    var queries = [showTitle].concat(aliases).filter(Boolean);

    for (var index = 0; index < queries.length; index += 1) {
      var query = queries[index];
      var searchHtml = await this.fetchText("/exchange/search/?q=" + encodeURIComponent(query));
      var seriesMatch = pickBestTitleMatch(parseSeriesMatches(searchHtml), showTitle || query);
      var episodePage = null;

      if (seriesMatch) {
        var seriesHtml = await this.fetchText("/exchange/series/" + seriesMatch.seriesId + "/");
        var episodeEntry = parseEpisodeLinks(seriesHtml).find(function findEpisode(entry) {
          return entry.seasonNumber === seasonNumber && entry.episodeNumber === episodeNumber;
        });

        if (episodeEntry) {
          episodePage = {
            videoId: episodeEntry.videoId,
            episodeTitle: episodeEntry.episodeTitle
          };
        }
      }

      if (!episodePage) {
        continue;
      }

      var videoHtml = await this.fetchText("/exchange/videos/" + episodePage.videoId + "/");
      var skipFiles = parseSkipFileEntries(videoHtml);
      if (!skipFiles.length) {
        continue;
      }

      var picked = pickBestSkipFile(skipFiles, categorySelection);
      if (!picked || !picked.entry || !picked.entry.sourceUrl) {
        continue;
      }

      var skipHtml = await this.fetchText(picked.entry.sourceUrl);
      var parsedRanges = parseRangesFromSkipText(
        parseSkipFileBody(skipHtml),
        this.baseUrl + picked.entry.sourceUrl,
        picked.entry.skipId
      );
      var matchingRanges = filterRangesByCategory(parsedRanges, categorySelection);

      if (!matchingRanges.length) {
        continue;
      }

      return {
        sourceName: "VideoSkip",
        sourceUrl: this.baseUrl + picked.entry.sourceUrl,
        exactRanges: matchingRanges,
        warningOnly: [],
        matchingSceneCount: matchingRanges.length,
        title: parseSourceTitle(skipHtml),
        downloads: picked.entry.downloads,
        syncedWith: picked.entry.syncedWith,
        filtersText: picked.entry.filtersText
      };
    }

    return null;
  };

  var api = {
    VideoSkipClient: VideoSkipClient,
    parseSeriesMatches: parseSeriesMatches,
    parseEpisodeLinks: parseEpisodeLinks,
    parseSkipFileEntries: parseSkipFileEntries,
    parseRangesFromSkipText: parseRangesFromSkipText
  };

  root.SafeSceneVideoSkip = api;

  if (typeof module !== "undefined" && module.exports) {
    module.exports = api;
  }
}(typeof globalThis !== "undefined" ? globalThis : this));
