/**
 * Copyright 2017 Google Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

const Location = require('./Location.js');
const colors = require('colors/safe');
const Diff = require('text-diff');
const GoldenUtils = require('./GoldenUtils');

class Matchers {
  constructor(customMatchers = {}) {
    this._matchers = {};
    Object.assign(this._matchers, DefaultMatchers);
    Object.assign(this._matchers, customMatchers);
    this.expect = this.expect.bind(this);
  }

  addMatcher(name, matcher) {
    this._matchers[name] = matcher;
  }

  expect(received) {
    return new Expect(received, this._matchers);
  }
};

class MatchError extends Error {
  constructor(message, formatter) {
    super(message);
    this.name = this.constructor.name;
    this.formatter = formatter;
    this.location = Location.getCallerLocation();
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = {Matchers, MatchError};

class Expect {
  constructor(received, matchers) {
    this.not = {};
    this.not.not = this;
    for (const matcherName of Object.keys(matchers)) {
      const matcher = matchers[matcherName];
      this[matcherName] = applyMatcher.bind(null, matcherName, matcher, false /* inverse */, received);
      this.not[matcherName] = applyMatcher.bind(null, matcherName, matcher, true /* inverse */, received);
    }

    function applyMatcher(matcherName, matcher, inverse, received, ...args) {
      const result = matcher.call(null, received, ...args);
      const message = `expect.${inverse ? 'not.' : ''}${matcherName} failed` + (result.message ? `: ${result.message}` : '');
      if (result.pass === inverse)
        throw new MatchError(message, result.formatter || defaultFormatter.bind(null, received));
    }
  }
}

function defaultFormatter(received) {
  return `Received: ${colors.red(JSON.stringify(received))}`;
}

function stringFormatter(received, expected) {
  const diff = new Diff();
  const result = diff.main(expected, received);
  diff.cleanupSemantic(result);
  const highlighted = result.map(([type, text]) => {
    if (type === -1)
      return colors.bgRed(text);
    if (type === 1)
      return colors.bgGreen.black(text);
    return text;
  }).join('');
  const output = [
    `Expected: ${expected}`,
    `Received: ${received}`,
    `    Diff: ${highlighted}`,
  ];
  for (let i = 0; i < Math.min(expected.length, received.length); ++i) {
    if (expected[i] !== received[i]) {
      const padding = ' '.repeat('    Diff: '.length);
      const firstDiffCharacter = '~'.repeat(i) + '^';
      output.push(colors.red(padding + firstDiffCharacter));
      break;
    }
  }
  return output.join('\n');
}

function objectFormatter(received, expected) {
  const encodingMap = new Map();
  const decodingMap = new Map();

  const doEncodeLines = (lines) => {
    let encoded = '';
    for (const line of lines) {
      let code = encodingMap.get(line);
      if (!code) {
        code = String.fromCodePoint(encodingMap.size);
        encodingMap.set(line, code);
        decodingMap.set(code, line);
      }
      encoded += code;
    }
    return encoded;
  };

  const doDecodeLines = (text) => {
    let decoded = [];
    for (const codepoint of [...text])
      decoded.push(decodingMap.get(codepoint));
    return decoded;
  }

  let receivedEncoded = doEncodeLines(received.split('\n'));
  let expectedEncoded = doEncodeLines(expected.split('\n'));

  const diff = new Diff();
  const result = diff.main(expectedEncoded, receivedEncoded);
  diff.cleanupSemantic(result);

  const highlighted = result.map(([type, text]) => {
    const lines = doDecodeLines(text);
    if (type === -1)
      return lines.map(line => '-   ' + colors.bgRed(line));
    if (type === 1)
      return lines.map(line => '+   ' + colors.bgGreen.black(line));
    return lines.map(line => '    ' + line);
  });

  const flattened = [];
  for (const list of highlighted)
    flattened.push(...list);
  return `Received:\n${flattened.join('\n')}`;
}

function toBeFormatter(received, expected) {
  if (typeof expected === 'string' && typeof received === 'string') {
    return stringFormatter(JSON.stringify(received), JSON.stringify(expected));
  }
  return [
    `Expected: ${JSON.stringify(expected)}`,
    `Received: ${colors.red(JSON.stringify(received))}`,
  ].join('\n');
}

const DefaultMatchers = {
  toBe: function(received, expected, message) {
    message = message || `${received} == ${expected}`;
    return { pass: received === expected, message, formatter: toBeFormatter.bind(null, received, expected) };
  },

  toBeFalsy: function(received, message) {
    message = message || `${received}`;
    return { pass: !received, message };
  },

  toBeTruthy: function(received, message) {
    message = message || `${received}`;
    return { pass: !!received, message };
  },

  toBeGreaterThan: function(received, other, message) {
    message = message || `${received} > ${other}`;
    return { pass: received > other, message };
  },

  toBeGreaterThanOrEqual: function(received, other, message) {
    message = message || `${received} >= ${other}`;
    return { pass: received >= other, message };
  },

  toBeLessThan: function(received, other, message) {
    message = message || `${received} < ${other}`;
    return { pass: received < other, message };
  },

  toBeLessThanOrEqual: function(received, other, message) {
    message = message || `${received} <= ${other}`;
    return { pass: received <= other, message };
  },

  toBeNull: function(received, message) {
    message = message || `${received} == null`;
    return { pass: received === null, message };
  },

  toContain: function(received, other, message) {
    message = message || `${received} ⊇ ${other}`;
    return { pass: received.includes(other), message };
  },

  toEqual: function(received, other, message) {
    let receivedJson = stringify(received);
    let otherJson = stringify(other);
    let formatter = objectFormatter.bind(null, receivedJson, otherJson);
    if (receivedJson.length < 40 && otherJson.length < 40) {
      receivedJson = receivedJson.split('\n').map(line => line.trim()).join(' ');
      otherJson = otherJson.split('\n').map(line => line.trim()).join(' ');
      formatter = stringFormatter.bind(null, receivedJson, otherJson);
    }
    message = message || `\n${receivedJson} ≈ ${otherJson}`;
    return { pass: receivedJson === otherJson, message, formatter };
  },

  toBeCloseTo: function(received, other, precision, message) {
    return {
      pass: Math.abs(received - other) < Math.pow(10, -precision),
      message
    };
  },

  toBeInstanceOf: function(received, other, message) {
    message = message || `${received.constructor.name} instanceof ${other.name}`;
    return { pass: received instanceof other, message };
  },

  toBeGolden: function(received, golden) {
    return GoldenUtils.compare(received, golden);
  },
};

function stringify(value) {
  function stabilize(key, object) {
    if (typeof object !== 'object' || object === undefined || object === null || Array.isArray(object))
      return object;
    const result = {};
    for (const key of Object.keys(object).sort())
      result[key] = object[key];
    return result;
  }

  return JSON.stringify(stabilize(null, value), stabilize, 2);
}
