/**
 * Copyright (c) 2019 The Android Open Source Project
 *
 * 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.
 */

'use strict';

// If you add or remove job types, do not forget to fix the colspans below.
const JOB_TYPES = [
  { id: 'linux-gcc7-x86_64-release', label: 'rel' },
  { id: 'linux-clang-x86_64-debug', label: 'dbg' },
  { id: 'linux-clang-x86_64-tsan', label: 'tsan' },
  { id: 'linux-clang-x86_64-msan', label: 'msan' },
  { id: 'linux-clang-x86_64-asan_lsan', label: '{a,l}san' },
  { id: 'linux-clang-x86-asan_lsan', label: 'x86 {a,l}san' },
  { id: 'linux-clang-x86_64-libfuzzer', label: 'fuzzer' },
  { id: 'linux-clang-x86_64-bazel', label: 'bazel' },
  { id: 'ui-clang-x86_64-release', label: 'rel' },
  { id: 'android-clang-arm-release', label: 'rel' },
  { id: 'android-clang-arm-asan', label: 'asan' },
];

const STATS_LINK =
    'https://app.google.stackdriver.com/dashboards/5008687313278081798?project=perfetto-ci';

const state = {
  // An array of recent CL objects retrieved from Gerrit.
  gerritCls: [],

  // A map of sha1 -> Gerrit commit object.
  // See
  // https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-commit
  gerritCommits: {},

  // A map of git-log ranges to commit objects:
  // 'dead..beef' -> [commit1, 2]
  gerritLogs: {},

  // Maps 'cls/1234-1' or 'branches/xxxx' -> array of job ids.
  dbJobSets: {},

  // Maps 'jobId' -> DB job object, as perf /ci/jobs/jobID.
  // A jobId looks like 20190702143507-1008614-9-android-clang-arm.
  dbJobs: {},

  // Maps 'worker id' -> DB wokrker object, as per /ci/workers.
  dbWorker: {},

  // Maps 'main-YYMMDD' -> DB branch object, as per /ci/branches/xxx.
  dbBranches: {},
  getBranchKeys: () => Object.keys(state.dbBranches).sort().reverse(),

  // Maps 'CL number' -> true|false. Retains the collapsed/expanded information
  // for each row in the CLs table.
  expandCl: {},

  postsubmitShown: 3,

  // Lines that will be appended to the terminal on the next redraw() cycle.
  termLines: [
    'Hover a CL icon to see the log tail.', 'Click on it to load the full log.'
  ],
  termJobId: undefined,  // The job id currently being shown by the terminal.
  termClear: false,      // If true the next redraw will clear the terminal.
  redrawPending: false,

  // State for the Jobs page. These are arrays of job ids.
  jobsQueued: [],
  jobsRunning: [],
  jobsRecent: [],

  // Firebase DB listeners (the objects returned by the .ref() operator).
  realTimeLogRef: undefined,  // Ref for the real-time log streaming.
  workersRef: undefined,
  jobsRunningRef: undefined,
  jobsQueuedRef: undefined,
  jobsRecentRef: undefined,
  clRefs: {},     // '1234-1' -> Ref subscribed to updates on the given cl.
  jobRefs: {},    // '....-arm-asan' -> Ref subscribed updates on the given job.
  branchRefs: {}  // 'main' -> Ref subscribed updates on the given branch.
};

let term = undefined;
let fitAddon = undefined;
let searchAddon = undefined;

function main() {
  firebase.initializeApp({ databaseURL: cfg.DB_ROOT });

  m.route(document.body, '/cls', {
    '/cls': CLsPageRenderer,
    '/cls/:cl': CLsPageRenderer,
    '/logs/:jobId': LogsPageRenderer,
    '/jobs': JobsPageRenderer,
    '/jobs/:jobId': JobsPageRenderer,
  });

  setInterval(fetchGerritCLs, 15000);
  fetchGerritCLs();
  fetchCIStatusForBranch('main');
}

// -----------------------------------------------------------------------------
// Rendering functions
// -----------------------------------------------------------------------------

function renderHeader() {
  const active = id => m.route.get().startsWith(`/${id}`) ? '.active' : '';
  const logUrl = 'https://goto.google.com/perfetto-ci-logs-';
  const docsUrl =
      'https://perfetto.dev/docs/design-docs/continuous-integration';
  return m(
      'header', m('a[href=/#!/cls]', m('h1', 'Perfetto ', m('span', 'CI'))),
      m(
          'nav',
          m(`div${active('cls')}`, m('a[href=/#!/cls]', 'CLs')),
          m(`div${active('jobs')}`, m('a[href=/#!/jobs]', 'Jobs')),
          m(`div${active('stats')}`,
            m(`a[href=${STATS_LINK}][target=_blank]`, 'Stats')),
          m(`div`, m(`a[href=${docsUrl}][target=_blank]`, 'Docs')),
          m(
              `div.logs`,
              'Logs',
              m('div',
                m(`a[href=${logUrl}controller][target=_blank]`, 'Controller')),
              m('div', m(`a[href=${logUrl}workers][target=_blank]`, 'Workers')),
              m('div',
                m(`a[href=${logUrl}frontend][target=_blank]`, 'Frontend')),
              ),
          ));
}

var CLsPageRenderer = {
  view: function (vnode) {
    const allCols = 4 + JOB_TYPES.length;
    const postsubmitHeader = m('tr',
      m(`td.header[colspan=${allCols}]`, 'Post-submit')
    );

    const postsubmitLoadMore = m('tr',
      m(`td[colspan=${allCols}]`,
        m('a[href=#]',
          { onclick: () => state.postsubmitShown += 10 },
          'Load more'
        )
      )
    );

    const presubmitHeader = m('tr',
      m(`td.header[colspan=${allCols}]`, 'Pre-submit')
    );

    let branchRows = [];
    const branchKeys = state.getBranchKeys();
    for (let i = 0; i < branchKeys.length && i < state.postsubmitShown; i++) {
      const rowsForBranch = renderPostsubmitRow(branchKeys[i]);
      branchRows = branchRows.concat(rowsForBranch);
    }

    let clRows = [];
    for (const gerritCl of state.gerritCls) {
      if (vnode.attrs.cl && gerritCl.num != vnode.attrs.cl) continue;
      clRows = clRows.concat(renderCLRow(gerritCl));
    }

    let footer = [];
    if (vnode.attrs.cl) {
      footer = m('footer',
        `Showing only CL ${vnode.attrs.cl} - `,
        m(`a[href=#!/cls]`, 'Click here to see all CLs')
      );
    }

    return [
      renderHeader(),
      m('main#cls',
        m('div.table-scrolling-container',
          m('table.main-table',
            m('thead',
              m('tr',
                m('td[rowspan=4]', 'Subject'),
                m('td[rowspan=4]', 'Status'),
                m('td[rowspan=4]', 'Owner'),
                m('td[rowspan=4]', 'Updated'),
                m('td[colspan=11]', 'Bots'),
              ),
              m('tr',
                m('td[colspan=9]', 'linux'),
                m('td[colspan=2]', 'android'),
              ),
              m('tr',
                m('td', 'gcc7'),
                m('td[colspan=7]', 'clang'),
                m('td[colspan=1]', 'ui'),
                m('td[colspan=2]', 'clang-arm'),
              ),
              m('tr#cls_header',
                JOB_TYPES.map(job => m(`td#${job.id}`, job.label))
              ),
            ),
            m('tbody',
              postsubmitHeader,
              branchRows,
              postsubmitLoadMore,
              presubmitHeader,
              clRows,
            )
          ),
          footer,
        ),
        m(TermRenderer),
      ),
    ];
  }
};


function getLastUpdate(lastUpdate) {
  const lastUpdateMins = Math.ceil((Date.now() - lastUpdate) / 60000);
  if (lastUpdateMins < 60)
    return lastUpdateMins + ' mins ago';
  if (lastUpdateMins < 60 * 24)
    return Math.ceil(lastUpdateMins / 60) + ' hours ago';
  return lastUpdate.toLocaleDateString();
}

function renderCLRow(cl) {
  const expanded = !!state.expandCl[cl.num];
  const toggleExpand = () => {
    state.expandCl[cl.num] ^= 1;
    fetchCIJobsForAllPatchsetOfCL(cl.num);
  }
  const rows = [];

  // Create the row for the latest patchset (as fetched by Gerrit).
  rows.push(m(`tr.${cl.status}`,
    m('td',
      m(`i.material-icons.expand${expanded ? '.expanded' : ''}`,
        { onclick: toggleExpand },
        'arrow_right'
      ),
      m(`a[href=${cfg.GERRIT_REVIEW_URL}/+/${cl.num}/${cl.psNum}]`,
        `${cl.subject}`, m('span.ps', `#${cl.psNum}`))
    ),
    m('td', cl.status),
    m('td', stripEmail(cl.owner || '')),
    m('td', getLastUpdate(cl.lastUpdate)),
    JOB_TYPES.map(x => renderClJobCell(`cls/${cl.num}-${cl.psNum}`, x.id))
  ));

  // If the usere clicked on the expand button, show also the other patchsets
  // present in the CI DB.
  for (let psNum = cl.psNum; expanded && psNum > 0; psNum--) {
    const src = `cls/${cl.num}-${psNum}`;
    const jobs = state.dbJobSets[src];
    if (!jobs) continue;
    rows.push(m(`tr.nested`,
      m('td',
        m(`a[href=${cfg.GERRIT_REVIEW_URL}/+/${cl.num}/${psNum}]`,
          '  Patchset', m('span.ps', `#${psNum}`))
      ),
      m('td', ''),
      m('td', ''),
      m('td', ''),
      JOB_TYPES.map(x => renderClJobCell(src, x.id))
    ));
  }

  return rows;
}

function renderPostsubmitRow(key) {
  const branch = state.dbBranches[key];
  console.assert(branch !== undefined);
  const subject = branch.subject;
  let rows = [];
  rows.push(m(`tr`,
    m('td',
      m(`a[href=${cfg.REPO_URL}/+/${branch.rev}]`,
        subject, m('span.ps', `#${branch.rev.substr(0, 8)}`)
      )
    ),
    m('td', ''),
    m('td', stripEmail(branch.author)),
    m('td', getLastUpdate(new Date(branch.time_committed))),
    JOB_TYPES.map(x => renderClJobCell(`branches/${key}`, x.id))
  ));


  const allKeys = state.getBranchKeys();
  const curIdx = allKeys.indexOf(key);
  if (curIdx >= 0 && curIdx < allKeys.length - 1) {
    const nextKey = allKeys[curIdx + 1];
    const range = `${state.dbBranches[nextKey].rev}..${branch.rev}`;
    const logs = (state.gerritLogs[range] || []).slice(1);
    for (const log of logs) {
      if (log.parents.length < 2)
        continue;  // Show only merge commits.
      rows.push(
        m('tr.nested',
          m('td',
            m(`a[href=${cfg.REPO_URL}/+/${log.commit}]`,
              log.message.split('\n')[0],
              m('span.ps', `#${log.commit.substr(0, 8)}`)
            )
          ),
          m('td', ''),
          m('td', stripEmail(log.author.email)),
          m('td', getLastUpdate(parseGerritTime(log.committer.time))),
          m(`td[colspan=${JOB_TYPES.length}]`,
            'No post-submit was run for this revision'
          ),
        )
      );
    }
  }

  return rows;
}

function renderJobLink(jobId, jobStatus) {
  const ICON_MAP = {
    'COMPLETED': 'check_circle',
    'STARTED': 'hourglass_full',
    'QUEUED': 'schedule',
    'FAILED': 'bug_report',
    'CANCELLED': 'cancel',
    'INTERRUPTED': 'cancel',
    'TIMED_OUT': 'notification_important',
  };
  const icon = ICON_MAP[jobStatus] || 'clear';
  const eventHandlers = jobId ? { onmouseover: () => showLogTail(jobId) } : {};
  const logUrl = jobId ? `#!/logs/${jobId}` : '#';
  return m(`a.${jobStatus}[href=${logUrl}][title=${jobStatus}]`,
    eventHandlers,
    m(`i.material-icons`, icon)
  );
}

function renderClJobCell(src, jobType) {
  let jobStatus = 'UNKNOWN';
  let jobId = undefined;

  // To begin with check that the given CL/PS is present in the DB (the
  // AppEngine cron job might have not seen that at all yet).
  // If it is, find the global job id for the given jobType for the passed CL.
  for (const id of (state.dbJobSets[src] || [])) {
    const job = state.dbJobs[id];
    if (job !== undefined && job.type == jobType) {
      // We found the job object that corresponds to jobType for the given CL.
      jobStatus = job.status;
      jobId = id;
    }
  }
  return m('td.job', renderJobLink(jobId, jobStatus));
}

const TermRenderer = {
  oncreate: function(vnode) {
    console.log('Creating terminal object');
    fitAddon = new FitAddon.FitAddon();
    searchAddon = new SearchAddon.SearchAddon();
    term = new Terminal({
      rows: 6,
      fontFamily: 'monospace',
      fontSize: 12,
      scrollback: 100000,
      disableStdin: true,
    });
    term.loadAddon(fitAddon);
    term.loadAddon(searchAddon);
    term.open(vnode.dom);
    fitAddon.fit();
    if (vnode.attrs.focused)
      term.focus();
  },
  onremove: function(vnode) {
    term.dispose();
    fitAddon.dispose();
    searchAddon.dispose();
  },
  onupdate: function(vnode) {
    fitAddon.fit();
    if (state.termClear) {
      term.clear();
      state.termClear = false;
    }
    for (const line of state.termLines) {
      term.write(line + '\r\n');
    }
    state.termLines = [];
  },
  view: function() {
    return m('.term-container',
      {
        onkeydown: (e) => {
          if (e.key === 'f' && (e.ctrlKey || e.metaKey)) {
            document.querySelector('.term-search').select();
            e.preventDefault();
          }
        }
      },
      m('input[type=text][placeholder=search and press Enter].term-search', {
        onkeydown: (e) => {
          if (e.key !== 'Enter') return;
          if (e.shiftKey) {
            searchAddon.findNext(e.target.value);
          } else {
            searchAddon.findPrevious(e.target.value);
          }
          e.stopPropagation();
          e.preventDefault();
        }
      })
    );
  }
};

const LogsPageRenderer = {
  oncreate: function (vnode) {
    showFullLog(vnode.attrs.jobId);
  },
  view: function () {
    return [
      renderHeader(),
      m(TermRenderer, { focused: true })
    ];
  }
}

const JobsPageRenderer = {
  oncreate: function (vnode) {
    fetchRecentJobsStatus();
    fetchWorkers();
  },

  createWorkerTable: function () {
    const makeWokerRow = workerId => {
      const worker = state.dbWorker[workerId];
      if (worker.status === 'TERMINATED') return [];
      return m('tr',
        m('td', worker.host),
        m('td', workerId),
        m('td', worker.status),
        m('td', getLastUpdate(new Date(worker.last_update))),
        m('td', m(`a[href=#!/jobs/${worker.job_id}]`, worker.job_id)),
      );
    };
    return m('table.main-table',
      m('thead',
        m('tr', m('td[colspan=5]', 'Workers')),
        m('tr',
          m('td', 'Host'),
          m('td', 'Worker'),
          m('td', 'Status'),
          m('td', 'Last ping'),
          m('td', 'Job'),
        )
      ),
      m('tbody', Object.keys(state.dbWorker).map(makeWokerRow))
    );
  },

  createJobsTable: function (vnode, title, jobIds) {
    const tStr = function (tStart, tEnd) {
      return new Date(tEnd - tStart).toUTCString().substr(17, 9);
    };

    const makeJobRow = function (jobId) {
      const job = state.dbJobs[jobId] || {};
      let cols = [
        m('td.job.align-left',
          renderJobLink(jobId, job ? job.status : undefined),
          m(`span.status.${job.status}`, job.status)
        )
      ];
      if (job) {
        const tQ = Date.parse(job.time_queued);
        const tS = Date.parse(job.time_started);
        const tE = Date.parse(job.time_ended) || Date.now();
        let cell = m('');
        if (job.src === undefined) {
          cell = '?';
        } else if (job.src.startsWith('cls/')) {
          const cl_and_ps = job.src.substr(4).replace('-', '/');
          const href = `${cfg.GERRIT_REVIEW_URL}/+/${cl_and_ps}`;
          cell = m(`a[href=${href}][target=_blank]`, cl_and_ps);
        } else if (job.src.startsWith('branches/')) {
          cell = job.src.substr(9).split('-')[0]
        }
        cols.push(m('td', cell));
        cols.push(m('td', `${job.type}`));
        cols.push(m('td', `${job.worker || ''}`));
        cols.push(m('td', `${job.time_queued}`));
        cols.push(m(`td[title=Start ${job.time_started}]`, `${tStr(tQ, tS)}`));
        cols.push(m(`td[title=End ${job.time_ended}]`, `${tStr(tS, tE)}`));
      } else {
        cols.push(m('td[colspan=6]', jobId));
      }
      return m(`tr${vnode.attrs.jobId === jobId ? '.selected' : ''}`, cols)
    };

    return m('table.main-table',
      m('thead',
        m('tr', m('td[colspan=7]', title)),

        m('tr',
          m('td', 'Status'),
          m('td', 'CL'),
          m('td', 'Type'),
          m('td', 'Worker'),
          m('td', 'T queued'),
          m('td', 'Queue time'),
          m('td', 'Run time'),
        )
      ),
      m('tbody', jobIds.map(makeJobRow))
    );
  },

  view: function (vnode) {
    return [
      renderHeader(),
      m('main',
        m('.jobs-list',
          this.createWorkerTable(),
          this.createJobsTable(vnode, 'Queued + Running jobs',
            state.jobsRunning.concat(state.jobsQueued)),
          this.createJobsTable(vnode, 'Last 100 jobs', state.jobsRecent),
        ),
      )
    ];
  }
};

// -----------------------------------------------------------------------------
// Business logic (handles fetching from Gerrit and Firebase DB).
// -----------------------------------------------------------------------------

function parseGerritTime(str) {
  // Gerrit timestamps are UTC (as per public docs) but obviously they are not
  // encoded in ISO format.
  return new Date(`${str} UTC`);
}

function stripEmail(email) {
  return email.replace('@google.com', '@');
}

// Fetches the list of CLs from gerrit and updates the state.
async function fetchGerritCLs() {
  console.log('Fetching CL list from Gerrit');
  let uri = '/gerrit/changes/?-age:7days';
  uri += '+-is:abandoned+branch:main&o=DETAILED_ACCOUNTS&o=CURRENT_REVISION';
  const response = await fetch(uri);
  state.gerritCls = [];
  if (response.status !== 200) {
    setTimeout(fetchGerritCLs, 3000);  // Retry.
    return;
  }

  const json = (await response.text());
  const cls = [];
  for (const e of JSON.parse(json)) {
    const revHash = Object.keys(e.revisions)[0];
    const cl = {
      subject: e.subject,
      status: e.status,
      num: e._number,
      revHash: revHash,
      psNum: e.revisions[revHash]._number,
      lastUpdate: parseGerritTime(e.updated),
      owner: e.owner.email,
    };
    cls.push(cl);
    fetchCIJobsForCLOrBranch(`cls/${cl.num}-${cl.psNum}`);
  }
  state.gerritCls = cls;
  scheduleRedraw();
}

async function fetchGerritCommit(sha1) {
  const response = await fetch(`/gerrit/commits/${sha1}`);
  console.assert(response.status === 200);
  const json = (await response.text());
  state.gerritCommits[sha1] = JSON.parse(json);
  scheduleRedraw();
}

async function fetchGerritLog(first, second) {
  const range = `${first}..${second}`;
  const response = await fetch(`/gerrit/log/${range}`);
  if (response.status !== 200) return;
  const json = await response.text();
  state.gerritLogs[range] = JSON.parse(json).log;
  scheduleRedraw();
}

// Retrieves the status of a given (CL, PS) in the DB.
function fetchCIJobsForCLOrBranch(src) {
  if (src in state.clRefs) return;  // Aslready have a listener for this key.
  const ref = firebase.database().ref(`/ci/${src}`);
  state.clRefs[src] = ref;
  ref.on('value', (e) => {
    const obj = e.val();
    if (!obj) return;
    state.dbJobSets[src] = Object.keys(obj.jobs);
    for (var jobId of state.dbJobSets[src]) {
      fetchCIStatusForJob(jobId);
    }
    scheduleRedraw();
  });
}

function fetchCIJobsForAllPatchsetOfCL(cl) {
  let ref = firebase.database().ref('/ci/cls').orderByKey();
  ref = ref.startAt(`${cl}-0`).endAt(`${cl}-~`);
  ref.once('value', (e) => {
    const patchsets = e.val() || {};
    for (const clAndPs in patchsets) {
      const jobs = Object.keys(patchsets[clAndPs].jobs);
      state.dbJobSets[`cls/${clAndPs}`] = jobs;
      for (var jobId of jobs) {
        fetchCIStatusForJob(jobId);
      }
    }
    scheduleRedraw();
  });
}

function fetchCIStatusForJob(jobId) {
  if (jobId in state.jobRefs) return;  // Already have a listener for this key.
  const ref = firebase.database().ref(`/ci/jobs/${jobId}`);
  state.jobRefs[jobId] = ref;
  ref.on('value', (e) => {
    if (e.val()) state.dbJobs[jobId] = e.val();
    scheduleRedraw();
  });
}

function fetchCIStatusForBranch(branch) {
  if (branch in state.branchRefs) return;  // Already have a listener.
  const db = firebase.database();
  const ref = db.ref('/ci/branches')
                  .orderByKey()
                  .startAt('main')
                  .endAt('maio')
                  .limitToLast(20);
  state.branchRefs[branch] = ref;
  ref.on('value', (e) => {
    const resp = e.val();
    if (!resp) return;
    // key looks like 'main-YYYYMMDDHHMMSS', where YMD is the commit datetime.
    // Iterate in most-recent-first order.
    const keys = Object.keys(resp).sort().reverse();
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i];
      const branchInfo = resp[key];
      state.dbBranches[key] = branchInfo;
      fetchCIJobsForCLOrBranch(`branches/${key}`);
      if (i < keys.length - 1) {
        fetchGerritLog(resp[keys[i + 1]].rev, branchInfo.rev);
      }
    }
    scheduleRedraw();
  });
}

function fetchWorkers() {
  if (state.workersRef !== undefined) return;  // Aslready have a listener.
  const ref = firebase.database().ref('/ci/workers');
  state.workersRef = ref;
  ref.on('value', (e) => {
    state.dbWorker = e.val() || {};
    scheduleRedraw();
  });
}

async function showLogTail(jobId) {
  if (state.termJobId === jobId) return;  // Already on it.
  const TAIL = 20;
  state.termClear = true;
  state.termLines = [
    `Fetching last ${TAIL} lines for ${jobId}.`,
    `Click on the CI icon to see the full log.`
  ];
  state.termJobId = jobId;
  scheduleRedraw();
  const ref = firebase.database().ref(`/ci/logs/${jobId}`);
  const lines = (await ref.orderByKey().limitToLast(TAIL).once('value')).val();
  if (state.termJobId !== jobId || !lines) return;
  const lastKey = appendLogLinesAndRedraw(lines);
  startRealTimeLogs(jobId, lastKey);
}

async function showFullLog(jobId) {
  state.termClear = true;
  state.termLines = [`Fetching full for ${jobId} ...`];
  state.termJobId = jobId;
  scheduleRedraw();

  // Suspend any other real-time logging in progress.
  stopRealTimeLogs();

  // Starts a chain of async tasks that fetch the current log lines in batches.
  state.termJobId = jobId;
  const ref = firebase.database().ref(`/ci/logs/${jobId}`).orderByKey();
  let lastKey = '';
  const BATCH = 1000;
  for (; ;) {
    const batchRef = ref.startAt(`${lastKey}!`).limitToFirst(BATCH);
    const logs = (await batchRef.once('value')).val();
    if (!logs)
      break;
    lastKey = appendLogLinesAndRedraw(logs);
  }

  startRealTimeLogs(jobId, lastKey)
}

function startRealTimeLogs(jobId, lastLineKey) {
  stopRealTimeLogs();
  console.log('Starting real-time logs for ', jobId);
  state.termJobId = jobId;
  let ref = firebase.database().ref(`/ci/logs/${jobId}`);
  ref = ref.orderByKey().startAt(`${lastLineKey}!`);
  state.realTimeLogRef = ref;
  state.realTimeLogRef.on('child_added', res => {
    const line = res.val();
    if (state.termJobId !== jobId || !line) return;
    const lines = {};
    lines[res.key] = line;
    appendLogLinesAndRedraw(lines);
  });
}

function stopRealTimeLogs() {
  if (state.realTimeLogRef !== undefined) {
    state.realTimeLogRef.off();
    state.realTimeLogRef = undefined;
  }
}

function appendLogLinesAndRedraw(lines) {
  const keys = Object.keys(lines).sort();
  for (var key of keys) {
    const date = new Date(null);
    date.setSeconds(parseInt(key.substr(0, 6), 16) / 1000);
    const timeString = date.toISOString().substr(11, 8);
    const isErr = lines[key].indexOf('FAILED:') >= 0;
    let line = `[${timeString}] ${lines[key]}`;
    if (isErr) line = `\u001b[33m${line}\u001b[0m`;
    state.termLines.push(line);
  }
  scheduleRedraw();
  return keys[keys.length - 1];
}

async function fetchRecentJobsStatus() {
  const db = firebase.database();
  if (state.jobsQueuedRef === undefined) {
    state.jobsQueuedRef = db.ref(`/ci/jobs_queued`).on('value', e => {
      state.jobsQueued = Object.keys(e.val() || {}).sort().reverse();
      for (const jobId of state.jobsQueued)
        fetchCIStatusForJob(jobId);
      scheduleRedraw();
    });
  }

  if (state.jobsRunningRef === undefined) {
    state.jobsRunningRef = db.ref(`/ci/jobs_running`).on('value', e => {
      state.jobsRunning = Object.keys(e.val() || {}).sort().reverse();
      for (const jobId of state.jobsRunning)
        fetchCIStatusForJob(jobId);
      scheduleRedraw();
    });
  }

  if (state.jobsRecentRef === undefined) {
    state.jobsRecentRef = db.ref(`/ci/jobs`).orderByKey().limitToLast(100);
    state.jobsRecentRef.on('value', e => {
      state.jobsRecent = Object.keys(e.val() || {}).sort().reverse();
      for (const jobId of state.jobsRecent)
        fetchCIStatusForJob(jobId);
      scheduleRedraw();
    });
  }
}


function scheduleRedraw() {
  if (state.redrawPending) return;
  state.redrawPending = true;
  window.requestAnimationFrame(() => {
    state.redrawPending = false;
    m.redraw();
  });
}

main();
