import path from "path";
import urlUtils from "url";

import sourceMap from "source-map-js";
import { decode } from "iconv-lite";

import parseDataURL from "./parse-data-url";
import labelsToNames from "./labels-to-names";

// Matches only the last occurrence of sourceMappingURL
const innerRegex = /\s*[#@]\s*sourceMappingURL\s*=\s*([^\s'"]*)\s*/;

/* eslint-disable prefer-template */
const sourceMappingURLRegex = RegExp(
  "(?:" +
    "/\\*" +
    "(?:\\s*\r?\n(?://)?)?" +
    "(?:" +
    innerRegex.source +
    ")" +
    "\\s*" +
    "\\*/" +
    "|" +
    "//(?:" +
    innerRegex.source +
    ")" +
    ")" +
    "\\s*"
);
/* eslint-enable prefer-template */

function labelToName(label) {
  const labelLowercase = String(label).trim().toLowerCase();

  return labelsToNames[labelLowercase] || null;
}

async function flattenSourceMap(map) {
  const consumer = await new sourceMap.SourceMapConsumer(map);
  const generatedMap = map.file
    ? new sourceMap.SourceMapGenerator({
        file: map.file,
      })
    : new sourceMap.SourceMapGenerator();

  consumer.sources.forEach((sourceFile) => {
    const sourceContent = consumer.sourceContentFor(sourceFile, true);
    generatedMap.setSourceContent(sourceFile, sourceContent);
  });

  consumer.eachMapping((mapping) => {
    const { source } = consumer.originalPositionFor({
      line: mapping.generatedLine,
      column: mapping.generatedColumn,
    });

    const mappings = {
      source,
      original: {
        line: mapping.originalLine,
        column: mapping.originalColumn,
      },
      generated: {
        line: mapping.generatedLine,
        column: mapping.generatedColumn,
      },
    };

    if (source) {
      generatedMap.addMapping(mappings);
    }
  });

  return generatedMap.toJSON();
}

function getSourceMappingURL(code) {
  const lines = code.split(/^/m);
  let match;

  for (let i = lines.length - 1; i >= 0; i--) {
    match = lines[i].match(sourceMappingURLRegex);
    if (match) {
      break;
    }
  }

  const sourceMappingURL = match ? match[1] || match[2] || "" : null;

  return {
    sourceMappingURL: sourceMappingURL
      ? decodeURI(sourceMappingURL)
      : sourceMappingURL,
    replacementString: match ? match[0] : null,
  };
}

function getAbsolutePath(context, request, sourceRoot) {
  if (isURL(sourceRoot)) {
    return new URL(request, sourceRoot).toString();
  }

  if (sourceRoot) {
    if (path.isAbsolute(sourceRoot)) {
      return path.join(sourceRoot, request);
    }

    return path.join(context, sourceRoot, request);
  }

  return path.join(context, request);
}

function fetchFromDataURL(loaderContext, sourceURL) {
  const dataURL = parseDataURL(sourceURL);

  if (dataURL) {
    // https://tools.ietf.org/html/rfc4627
    // JSON text SHALL be encoded in Unicode. The default encoding is UTF-8.
    const encodingName =
      labelToName(dataURL.parameters.get("charset")) || "UTF-8";

    return decode(dataURL.body, encodingName);
  }

  throw new Error(`Failed to parse source map from "data" URL: ${sourceURL}`);
}

async function fetchFromFilesystem(loaderContext, sourceURL) {
  let buffer;

  if (isURL(sourceURL)) {
    return { path: sourceURL };
  }

  try {
    buffer = await new Promise((resolve, reject) => {
      loaderContext.fs.readFile(sourceURL, (error, data) => {
        if (error) {
          return reject(error);
        }

        return resolve(data);
      });
    });
  } catch (error) {
    throw new Error(
      `Failed to parse source map from '${sourceURL}' file: ${error}`
    );
  }

  return { path: sourceURL, data: buffer.toString() };
}

async function fetchPathsFromFilesystem(
  loaderContext,
  possibleRequests,
  errorsAccumulator = ""
) {
  let result;

  try {
    result = await fetchFromFilesystem(
      loaderContext,
      possibleRequests[0],
      errorsAccumulator
    );
  } catch (error) {
    // eslint-disable-next-line no-param-reassign
    errorsAccumulator += `${error.message}\n\n`;

    const [, ...tailPossibleRequests] = possibleRequests;

    if (tailPossibleRequests.length === 0) {
      error.message = errorsAccumulator;

      throw error;
    }

    return fetchPathsFromFilesystem(
      loaderContext,
      tailPossibleRequests,
      errorsAccumulator
    );
  }

  return result;
}

function isURL(value) {
  return /^[a-z][a-z0-9+.-]*:/i.test(value) && !path.win32.isAbsolute(value);
}

async function fetchFromURL(
  loaderContext,
  context,
  url,
  sourceRoot,
  skipReading = false
) {
  // 1. It's an absolute url and it is not `windows` path like `C:\dir\file`
  if (isURL(url)) {
    const { protocol } = urlUtils.parse(url);

    if (protocol === "data:") {
      if (skipReading) {
        return { sourceURL: "" };
      }

      const sourceContent = fetchFromDataURL(loaderContext, url);

      return { sourceURL: "", sourceContent };
    }

    if (skipReading) {
      return { sourceURL: url };
    }

    if (protocol === "file:") {
      const pathFromURL = urlUtils.fileURLToPath(url);
      const sourceURL = path.normalize(pathFromURL);
      const { data: sourceContent } = await fetchFromFilesystem(
        loaderContext,
        sourceURL
      );

      return { sourceURL, sourceContent };
    }

    throw new Error(
      `Failed to parse source map: '${url}' URL is not supported`
    );
  }

  // 2. It's a scheme-relative
  if (/^\/\//.test(url)) {
    throw new Error(
      `Failed to parse source map: '${url}' URL is not supported`
    );
  }

  // 3. Absolute path
  if (path.isAbsolute(url)) {
    let sourceURL = path.normalize(url);

    let sourceContent;

    if (!skipReading) {
      const possibleRequests = [sourceURL];

      if (url.startsWith("/")) {
        possibleRequests.push(
          getAbsolutePath(context, sourceURL.slice(1), sourceRoot)
        );
      }

      const result = await fetchPathsFromFilesystem(
        loaderContext,
        possibleRequests
      );

      sourceURL = result.path;
      sourceContent = result.data;
    }

    return { sourceURL, sourceContent };
  }

  // 4. Relative path
  const sourceURL = getAbsolutePath(context, url, sourceRoot);

  let sourceContent;

  if (!skipReading) {
    const { data } = await fetchFromFilesystem(loaderContext, sourceURL);

    sourceContent = data;
  }

  return { sourceURL, sourceContent };
}

export { getSourceMappingURL, fetchFromURL, flattenSourceMap, isURL };
