var fs = require('fs');
var path = require('path');

var isAllowedResource = require('./is-allowed-resource');
var matchDataUri = require('./match-data-uri');
var rebaseLocalMap = require('./rebase-local-map');
var rebaseRemoteMap = require('./rebase-remote-map');

var Token = require('../tokenizer/token');
var hasProtocol = require('../utils/has-protocol');
var isDataUriResource = require('../utils/is-data-uri-resource');
var isRemoteResource = require('../utils/is-remote-resource');

var MAP_MARKER_PATTERN = /^\/\*# sourceMappingURL=(\S+) \*\/$/;

function applySourceMaps(tokens, context, callback) {
  var applyContext = {
    callback: callback,
    fetch: context.options.fetch,
    index: 0,
    inline: context.options.inline,
    inlineRequest: context.options.inlineRequest,
    inlineTimeout: context.options.inlineTimeout,
    inputSourceMapTracker: context.inputSourceMapTracker,
    localOnly: context.localOnly,
    processedTokens: [],
    rebaseTo: context.options.rebaseTo,
    sourceTokens: tokens,
    warnings: context.warnings
  };

  return context.options.sourceMap && tokens.length > 0 ?
    doApplySourceMaps(applyContext) :
    callback(tokens);
}

function doApplySourceMaps(applyContext) {
  var singleSourceTokens = [];
  var lastSource = findTokenSource(applyContext.sourceTokens[0]);
  var source;
  var token;
  var l;

  for (l = applyContext.sourceTokens.length; applyContext.index < l; applyContext.index++) {
    token = applyContext.sourceTokens[applyContext.index];
    source = findTokenSource(token);

    if (source != lastSource) {
      singleSourceTokens = [];
      lastSource = source;
    }

    singleSourceTokens.push(token);
    applyContext.processedTokens.push(token);

    if (token[0] == Token.COMMENT && MAP_MARKER_PATTERN.test(token[1])) {
      return fetchAndApplySourceMap(token[1], source, singleSourceTokens, applyContext);
    }
  }

  return applyContext.callback(applyContext.processedTokens);
}

function findTokenSource(token) {
  var scope;
  var metadata;

  if (token[0] == Token.AT_RULE || token[0] == Token.COMMENT || token[0] == Token.RAW) {
    metadata = token[2][0];
  } else {
    scope = token[1][0];
    metadata = scope[2][0];
  }

  return metadata[2];
}

function fetchAndApplySourceMap(sourceMapComment, source, singleSourceTokens, applyContext) {
  return extractInputSourceMapFrom(sourceMapComment, applyContext, function (inputSourceMap) {
    if (inputSourceMap) {
      applyContext.inputSourceMapTracker.track(source, inputSourceMap);
      applySourceMapRecursively(singleSourceTokens, applyContext.inputSourceMapTracker);
    }

    applyContext.index++;
    return doApplySourceMaps(applyContext);
  });
}

function extractInputSourceMapFrom(sourceMapComment, applyContext, whenSourceMapReady) {
  var uri = MAP_MARKER_PATTERN.exec(sourceMapComment)[1];
  var absoluteUri;
  var sourceMap;
  var rebasedMap;

  if (isDataUriResource(uri)) {
    sourceMap = extractInputSourceMapFromDataUri(uri);
    return whenSourceMapReady(sourceMap);
  } else if (isRemoteResource(uri)) {
    return loadInputSourceMapFromRemoteUri(uri, applyContext, function (sourceMap) {
      var parsedMap;

      if (sourceMap) {
        parsedMap = JSON.parse(sourceMap);
        rebasedMap = rebaseRemoteMap(parsedMap, uri);
        whenSourceMapReady(rebasedMap);
      } else {
        whenSourceMapReady(null);
      }
    });
  } else {
    // at this point `uri` is already rebased, see lib/reader/rebase.js#rebaseSourceMapComment
    // it is rebased to be consistent with rebasing other URIs
    // however here we need to resolve it back to read it from disk
    absoluteUri = path.resolve(applyContext.rebaseTo, uri);
    sourceMap = loadInputSourceMapFromLocalUri(absoluteUri, applyContext);

    if (sourceMap) {
      rebasedMap = rebaseLocalMap(sourceMap, absoluteUri, applyContext.rebaseTo);
      return whenSourceMapReady(rebasedMap);
    } else {
      return whenSourceMapReady(null);
    }
  }
}

function extractInputSourceMapFromDataUri(uri) {
  var dataUriMatch = matchDataUri(uri);
  var charset = dataUriMatch[2] ? dataUriMatch[2].split(/[=;]/)[2] : 'us-ascii';
  var encoding = dataUriMatch[3] ? dataUriMatch[3].split(';')[1] : 'utf8';
  var data = encoding == 'utf8' ? global.unescape(dataUriMatch[4]) : dataUriMatch[4];

  var buffer = Buffer.from(data, encoding);
  buffer.charset = charset;

  return JSON.parse(buffer.toString());
}

function loadInputSourceMapFromRemoteUri(uri, applyContext, whenLoaded) {
  var isAllowed = isAllowedResource(uri, true, applyContext.inline);
  var isRuntimeResource = !hasProtocol(uri);

  if (applyContext.localOnly) {
    applyContext.warnings.push('Cannot fetch remote resource from "' + uri + '" as no callback given.');
    return whenLoaded(null);
  } else if (isRuntimeResource) {
    applyContext.warnings.push('Cannot fetch "' + uri + '" as no protocol given.');
    return whenLoaded(null);
  } else if (!isAllowed) {
    applyContext.warnings.push('Cannot fetch "' + uri + '" as resource is not allowed.');
    return whenLoaded(null);
  }

  applyContext.fetch(uri, applyContext.inlineRequest, applyContext.inlineTimeout, function (error, body) {
    if (error) {
      applyContext.warnings.push('Missing source map at "' + uri + '" - ' + error);
      return whenLoaded(null);
    }

    whenLoaded(body);
  });
}

function loadInputSourceMapFromLocalUri(uri, applyContext) {
  var isAllowed = isAllowedResource(uri, false, applyContext.inline);
  var sourceMap;

  if (!fs.existsSync(uri) || !fs.statSync(uri).isFile()) {
    applyContext.warnings.push('Ignoring local source map at "' + uri + '" as resource is missing.');
    return null;
  } else if (!isAllowed) {
    applyContext.warnings.push('Cannot fetch "' + uri + '" as resource is not allowed.');
    return null;
  } else if (!fs.statSync(uri).size) {
    applyContext.warnings.push('Cannot fetch "' + uri + '" as resource is empty.');
    return null;
  }

  sourceMap = fs.readFileSync(uri, 'utf-8');
  return JSON.parse(sourceMap);
}

function applySourceMapRecursively(tokens, inputSourceMapTracker) {
  var token;
  var i, l;

  for (i = 0, l = tokens.length; i < l; i++) {
    token = tokens[i];

    switch (token[0]) {
      case Token.AT_RULE:
        applySourceMapTo(token, inputSourceMapTracker);
        break;
      case Token.AT_RULE_BLOCK:
        applySourceMapRecursively(token[1], inputSourceMapTracker);
        applySourceMapRecursively(token[2], inputSourceMapTracker);
        break;
      case Token.AT_RULE_BLOCK_SCOPE:
        applySourceMapTo(token, inputSourceMapTracker);
        break;
      case Token.NESTED_BLOCK:
        applySourceMapRecursively(token[1], inputSourceMapTracker);
        applySourceMapRecursively(token[2], inputSourceMapTracker);
        break;
      case Token.NESTED_BLOCK_SCOPE:
        applySourceMapTo(token, inputSourceMapTracker);
        break;
      case Token.COMMENT:
        applySourceMapTo(token, inputSourceMapTracker);
        break;
      case Token.PROPERTY:
        applySourceMapRecursively(token, inputSourceMapTracker);
        break;
      case Token.PROPERTY_BLOCK:
        applySourceMapRecursively(token[1], inputSourceMapTracker);
        break;
      case Token.PROPERTY_NAME:
        applySourceMapTo(token, inputSourceMapTracker);
        break;
      case Token.PROPERTY_VALUE:
        applySourceMapTo(token, inputSourceMapTracker);
        break;
      case Token.RULE:
        applySourceMapRecursively(token[1], inputSourceMapTracker);
        applySourceMapRecursively(token[2], inputSourceMapTracker);
        break;
      case Token.RULE_SCOPE:
        applySourceMapTo(token, inputSourceMapTracker);
    }
  }

  return tokens;
}

function applySourceMapTo(token, inputSourceMapTracker) {
  var value = token[1];
  var metadata = token[2];
  var newMetadata = [];
  var i, l;

  for (i = 0, l = metadata.length; i < l; i++) {
    newMetadata.push(inputSourceMapTracker.originalPositionFor(metadata[i], value.length));
  }

  token[2] = newMetadata;
}

module.exports = applySourceMaps;
