////
//// Setup
////
import { diffChars } from 'diff'
import 'bootstrap/dist/css/bootstrap.min.css';

const arraysEqual = (a, b) => {
  return a.length === b.length && a.every((v, i) => v === b[i]);
}

// dependency for the count of tests running/passed/failed, etc. drives
// the navbar and the like.
var countDep = new Tracker.Dependency;
// things that change on countDep
var running = true;
var totalCount = 0;
var passedCount = 0;
var failedCount = 0;
var failedTests = [];

// Dependency for when a new top level group is added. Each group and
// each test have their own dependency objects.
var topLevelGroupsDep = new Tracker.Dependency;

// An array of top-level groups.
//
// Each group is an object with:
// - name: string
// - path: array of strings (names of parent groups)
// - parent: parent group object (back reference)
// - dep: Tracker.Dependency object for this group. fires when new tests added.
// - groups: list of sub-groups
// - tests: list of tests in this group
//
// Each test is an object with:
// - name: string
// - parent: parent group object (back reference)
// - server: boolean
// - fullName: string
// - dep: Tracker.Dependency object for this test. fires when the test completes.
var resultTree = [];

Session.set("uncaughtErrors", []);
window.onerror = (message, source, line) => {
  const uncaughtErrors = new Set(Session.get("uncaughtErrors"));
  uncaughtErrors.add(message);
  Session.set("uncaughtErrors", Array.from(uncaughtErrors));
};

var getGroupPathFromURL = function() {
  var pathname = window.location.pathname;
  var match = pathname.match(/^\/group\/(.+)$/);
  if (match) {
    try {
      return JSON.parse(decodeURIComponent(match[1]));
    } catch (e) {
      console.warn('Invalid group path in URL:', match[1]);
    }
  }
  return ["tinytest"];
};

var setGroupPathInURL = function(groupPath, pushState = true) {
  var newURL = '/';

  if (!arraysEqual(groupPath, ['tinytest'])) {
    newURL = '/group/' + encodeURIComponent(JSON.stringify(groupPath));
  }
  
  var historyState = { groupPath: groupPath };
  
  if (pushState) {
    window.history.pushState(historyState, '', newURL);
  } else {
    window.history.replaceState(historyState, '', newURL);
  }
};

// Initialize group path from URL, fallback to default
var initialGroupPath = getGroupPathFromURL();
Session.setDefault("groupPath", initialGroupPath);
Session.set("rerunScheduled", false);

// Safeguards for rapid navigation
var isNavigating = false;
var lastNavigationTime = 0;
var navigationDebounceMs = 200; // Minimum time between navigations

// Handle browser back/forward navigation
window.addEventListener('popstate', function(event) {
  var now = Date.now();
  
  // Safeguard 1: Prevent overlapping navigations
  if (isNavigating) {
    console.log('Navigation already in progress, ignoring');
    return;
  }
  
  // Safeguard 2: Debounce rapid successive calls
  if (now - lastNavigationTime < navigationDebounceMs) {
    console.log('Navigation too rapid, ignoring');
    return;
  }
  
  var newGroupPath = getGroupPathFromURL();
  var currentGroupPath = Session.get("groupPath");
  
  if (!arraysEqual(newGroupPath, currentGroupPath)) {
    // Set navigation flag
    isNavigating = true;
    lastNavigationTime = now;
    
    // Emulate the EXACT same sequence as changeToPath
    
    // 1. URL is already changed by browser, but ensure it's correct
    setGroupPathInURL(newGroupPath, false); // replaceState, don't create new entry
    
    // 2. Update session state (SAME as changeToPath)
    Session.set("groupPath", newGroupPath);
    Session.set("rerunScheduled", true);
    
    // 3. Clean reload (SAME as changeToPath)
    Reload._reload();
  }
});

// This function is exported. It's called on client startup by the
// bundle generated by `meteor test` or `meteor test-packages`.
runTests = function () {
  // Reset navigation safeguards when app starts
  isNavigating = false;
  
  // Reset all test state before starting
  running = true;
  totalCount = 0;
  passedCount = 0;
  failedCount = 0;
  failedTests = [];
  resultTree = [];
  
  // Reset dependencies to trigger UI updates
  countDep.changed();
  topLevelGroupsDep.changed();
  
  // Get current group path from URL (in case of refresh)
  var currentGroupPath = getGroupPathFromURL();
  Session.set("groupPath", currentGroupPath);
  
  // Only update URL if it's actually different from what we expect
  var expectedURL;
  if (currentGroupPath && currentGroupPath.length > 0 && JSON.stringify(currentGroupPath) !== JSON.stringify(["tinytest"])) {
    expectedURL = '/group/' + encodeURIComponent(JSON.stringify(currentGroupPath));
  } else {
    expectedURL = '/';
  }
  
  // Only do replaceState if the URL doesn't match what we expect
  if (window.location.pathname !== expectedURL) {
    setGroupPathInURL(currentGroupPath, false);
  }
  
  document.body.innerHTML = "";
  document.head.title = "Tests";

  Blaze.render(Template.testInBrowserBody, document.body);

  Tracker.flush();
  Tinytest._runTestsEverywhere(reportResults, function () {
    running = false;
    Meteor.onTestsComplete && Meteor.onTestsComplete();
    countDep.changed();
    Tracker.flush();

    Meteor.connection._unsubscribeAll();
  }, currentGroupPath);

};


////
//// Take incoming results and drive resultsTree
////

// report a series of events in a single test, or just the existence of
// that test if no events. this is the entry point for test results to
// this module.
var reportResults = function(results) {
  var test = _findTestForResults(results);

  // Tolerate repeated reports: first undo the effect of any previous report
  var status = _testStatus(test);
  if (status === "failed") {
    failedCount--;
    countDep.changed();
  } else if (status === "succeeded") {
    passedCount--;
    countDep.changed();
  }

  // Now process the current report
  if (Array.isArray(results.events)) {
    // append events, if present
    Array.prototype.push.apply((test.events || (test.events = [])),
                               results.events);
    // sort and de-duplicate, based on sequence number
    test.events.sort(function (a, b) {
      return a.sequence - b.sequence;
    });
    var out = [];
    test.events.forEach(function (e) {
      if (out.length === 0 || out[out.length - 1].sequence !== e.sequence)
        out.push(e);
    });
    test.events = out;
  }
  status = _testStatus(test);
  if (status === "failed") {
    failedCount++;
    // Expand a failed test (but only set this if the user hasn't clicked on the
    // test name yet).
    if (test.expanded === undefined)
      test.expanded = true;
    if (!failedTests.includes(test.fullName))
      failedTests.push(test.fullName);

    countDep.changed();
    test.dep.changed();
  } else if (status === "succeeded") {
    passedCount++;
    countDep.changed();
    test.dep.changed();
  } else if (test.expanded) {
    // re-render the test if new results come in and the test is
    // currently expanded.
    test.dep.changed();
  }
};

// forget all of the events for a particular test
var forgetEvents = function (results) {
  var test = _findTestForResults(results);
  var status = _testStatus(test);
  if (status === "failed") {
    failedCount--;
    countDep.changed();
  } else if (status === "succeeded") {
    passedCount--;
    countDep.changed();
  }
  delete test.events;
  test.dep.changed();
};

// given a 'results' as delivered via reportResults, find the
// corresponding leaf object in resultTree, creating one if it doesn't
// exist. it will be an object with attributes 'name', 'parent', and
// possibly 'events'.
var _findTestForResults = function (results) {
  var groupPath = results.groupPath; // array
  if ((! Array.isArray(groupPath)) || (groupPath.length < 1)) {
    throw new Error("Test must be part of a group");
  }

  var group;
  var i = 0;
  groupPath.forEach(function(gname) {
    var array = (group ? (group.groups || (group.groups = []))
                 : resultTree);
    var newGroup = array.find(function(g) { return g.name === gname; });
    if (! newGroup) {
      newGroup = {
        name: gname,
        parent: (group || null),
        path: groupPath.slice(0, i+1),
        dep: new Tracker.Dependency
      }; // create group
      array.push(newGroup);

      if (group)
        group.dep.changed();
      else
        topLevelGroupsDep.changed();
    }
    group = newGroup;
    i++;
  });

  var testName = results.test;
  var server = !!results.server;
  var test = (group.tests || (group.tests = [])).find(
                    function(t) { return t.name === testName &&
                                  t.server === server; });
  if (! test) {
    // create test
    var nameParts = [...groupPath];
    nameParts.push(testName);
    var fullName = nameParts.join(' - ');
    test = {
      name: testName,
      parent: group,
      server: server,
      fullName: fullName,
      dep: new Tracker.Dependency
    };
    group.tests.push(test);
    group.dep.changed();
    totalCount++;
    countDep.changed();
  }

  return test;
};



////
//// Helpers on test objects
////

var _testTime = function(t) {
  if (t.events && t.events.length > 0) {
    var lastEvent = t.events[t.events.length - 1];
    if (lastEvent.type === "finish") {
      if ((typeof lastEvent.timeMs) === "number") {
        return lastEvent.timeMs;
      }
    }
  }
  return null;
};

var _testStatus = function(t) {
  var events = t.events || [];
  if (events.find(function(x) { return x.type === "exception"; })) {
    // "exception" should be last event, except race conditions on the
    // server can make this not the case.  Technically we can't tell
    // if the test is still running at this point, but it can only
    // result in FAIL.
    return "failed";
  } else if (events.length == 0 || (events[events.length - 1].type != "finish")) {
    return "running";
  } else if (events.some(function(e) {
    return e.type == "fail" || e.type == "exception"; })) {
    return "failed";
  } else {
    return "succeeded";
  }
};



////
//// Templates
////

//// Template - navBars

Template.navBar.helpers({
  running: function() {
    countDep.depend();
    return running;
  },
  passed: function() {
    countDep.depend();
    return failedCount === 0;
  },
  total_test_time: function() {
    countDep.depend();

    // walk whole tree to get all tests
    var walk = function (groups) {
      var total = 0;

      (groups || []).forEach(function (group) {
        (group.tests || []).forEach(function (t) {
          total += _testTime(t);
        });

        total += walk(group.groups);
      });

      return total;
    };

    return walk(resultTree);
  }
});


//// Template - progressBar

Template.progressBar.helpers({
  running: function () {
    countDep.depend();
    return running;
  },
  percentPass: function () {
    countDep.depend();
    if (totalCount === 0)
      return 0;
    return 100*passedCount/totalCount;
  },
  totalCount: function () {
    countDep.depend();
    return totalCount;
  },
  passedCount: function () {
    countDep.depend();
    return passedCount;
  },
  percentFail: function () {
    countDep.depend();
    if (totalCount === 0)
      return 0;
    return 100*failedCount/totalCount;
  },
  anyFail: function () {
    countDep.depend();
    return failedCount > 0;
  },
  barOuterClass: function () {
    countDep.depend();
    return running ? 'progress-bar-animated progress-bar-striped' : '';
  },
  barInnerClass: function () {
    countDep.depend();
    return (failedCount > 0 ?
            'bg-warning' : 'bg-success');
  }
});

//// Template - groupNav

var changeToPath = function (path) {
  // Update URL with new group path (pushState creates new history entry)
  setGroupPathInURL(path, true);
  
  // Update session to trigger UI updates
  Session.set("groupPath", path);
  Session.set("rerunScheduled", true);
  // pretend there's just been a hot code push
  // so we run the tests completely fresh.
  Reload._reload();
};

Template.groupNav.helpers({
  groupPaths: function () {
    var groupPath = Session.get("groupPath");
    var ret = [];
    for (var i = 1; i <= groupPath.length; i++) {
      ret.push({path: groupPath.slice(0,i), name: groupPath[i-1]});
    }
    return ret;
  },
  rerunScheduled: function () {
    return Session.get("rerunScheduled");
  },
  isFiltered: function () {
    var groupPath = Session.get("groupPath");
    return groupPath.length > 1 || groupPath[0] !== "tinytest";
  }
});

Template.groupNav.events({
  'click .group': function () {
    changeToPath(this.path);
  },
  'click .rerun': function () {
    Session.set("rerunScheduled", true);
    Reload._reload();
  },
  'click .run-all': function () {
    changeToPath(["tinytest"]);
  }
});

Template.groupNav.onRendered(function () {
  Tinytest._onCurrentClientTest = function (name) {
    name = (name ? 'C: '+name : '');
    // Set the DOM directly so that it's immediate and we
    // don't wait for Tracker to flush.
    var span = document.getElementById('current-client-test');
    if (span) {
      span.innerHTML = '';
      span.appendChild(document.createTextNode(name));
    }
  };
});

//// Template - uncaughtErrors

Template.uncaughtErrors.helpers({
  uncaughtErrors() {
    return Session.get("uncaughtErrors");
  }
});

//// Template - failedTests

Template.failedTests.helpers({
  failedTests: function() {
    countDep.depend();
    return failedTests;
  }
});

//// Template - testTable

Template.testTable.helpers({
  testdata: function () {
    topLevelGroupsDep.depend();
    return resultTree;
  },
  thisWithDep: function () {
    this.dep.depend();
    return this;
  }
});

//// Template - test_group

Template.test_group.helpers({
  thisWithDep: function () {
    this.dep.depend();
    return this;
  }
});

Template.test_group.events({
  'click .groupname': function (evt) {
    changeToPath(this.path);
    // prevent enclosing groups from also triggering on
    // same groupname.  It would be cleaner to think of
    // this as each group only listening to its *own*
    // groupname, but currently it listens to all of them.
    evt.stopImmediatePropagation();
  }
});


//// Template - test

Template.test.helpers({
  test_status_display: function() {
    var status = _testStatus(this);
    if (status == "failed") {
      return "FAIL";
    } else if (status == "succeeded") {
      return "PASS";
    } else {
      return "waiting...";
    }
  },

  test_time_display: function() {
    var time = _testTime(this);
    return (typeof time === "number") ? time + " ms" : "";
  },

  test_class: function() {
    var events = this.events || [];
    var classes = [_testStatus(this)];

    if (this.expanded) {
      classes.push("expanded");
    } else {
      classes.push("collapsed");
    }

    return classes.join(' ');
  },

  eventsArray: function() {
    var events = this.events.filter(function(e) {
      return e.type != "finish";
    });

    var partitionBy = function(seq, func) {
      var result = [];
      var lastValue = {};
      seq.forEach(function(x) {
        var newValue = func(x);
        if (newValue === lastValue) {
          result[result.length-1].push(x);
        } else {
          lastValue = newValue;
          result.push([x]);
        }
      });
      return result;
    };

    var dupLists = partitionBy(
      events.map(function(e) {
        // XXX XXX We need something better than stringify!
        // stringify([undefined]) === "[null]"
        e = Object.assign({}, e);
        delete e.sequence;
        return {obj: e, str: JSON.stringify(e)};
      }), function(x) { return x.str; });

    return dupLists.map(function(L) {
      var obj = L[0].obj;
      return (L.length > 1) ? Object.assign({times: L.length}, obj) : obj;
    });
  }
});

Template.test.events({
  'click .testname': function () {
    this.expanded = ! this.expanded;
    this.dep.changed();
  }
});


//// Template - event

Template.event.events({
  'click .debug': function () {
    // the way we manage groupPath, shortName, cookies, etc, is really
    // messy. needs to be aggressively refactored.
    forgetEvents({groupPath: this.cookie.groupPath,
                  test: this.cookie.shortName});
    Tinytest._debugTest(this.cookie, reportResults);
  }
});

// e.g. doDiff('abc', 'bcd') => [[-1, 'a'], [0, 'bc'], [1, 'd']]
var doDiff = function (str1, str2) {
  const diff = diffChars(str1, str2);
  
  return diff.map(part => {
    if (part.added) return [1, part.value];
    if (part.removed) return [-1, part.value];
    return [0, part.value];
  });
};

Template.event.helpers({
  get_details: function() {

    var details = this.details;

    if (! details) {
      return null;
    } else {

      var type = details.type;
      var stack = details.stack;

      details = Array.isArray(details) && [...details] || Object.assign({}, details);
      delete details.type;
      delete details.stack;

      var prepare = function(details) {
        if (type === 'string_equal') {
          var diff = doDiff(details.actual,
                            details.expected);
        }

        return Object.entries(details).map(function([key, val]) {

          // make test._stringEqual results print nicely,
          // in particular for multiline strings
          if (type === 'string_equal' &&
              (key === 'actual' || key === 'expected')) {
            var html = '<pre class="string_equal string_equal_'+key+'">';
            diff.forEach(function (piece) {
              var which = piece[0];
              var text = piece[1];
              if (which === 0 ||
                  which === (key === 'actual' ? -1 : 1)) {
                var htmlBit = Blaze._escape(text).replace(
                    /\n/g, '<br>');
                if (which !== 0)
                  htmlBit = '<ins>' + htmlBit + '</ins>';
                html += htmlBit;
              }
            });
            html += '</pre>';
            val = new Spacebars.SafeString(html);
          }

          // You can end up with a an undefined value, e.g. using
          // isNull without providing a message attribute: isNull(1).
          // No need to display those.
          if (typeof val !== 'undefined') {
            return {
              key: key,
              val: val
            };
          } else {
            return undefined;
          }
        }).filter(Boolean);
      };

      return {
        type: type,
        stack: stack,
        details: prepare(details)
      };
    }
  },

  is_debuggable: function() {
    return !!this.cookie;
  }
});
