/**
 * 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/gcc9-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-release", label: "x86 rel" },
  { id: "fuzzer", label: "fuzzer" },
  { id: "bazel", label: "bazel" },
  { id: "ui", label: "rel" },
  { id: "android", label: "rel" },
  { id: "repo-checks", label: "RC" },
];

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

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

  // pullReqs.id -> {JOB_TYPES.id -> {status, url}}
  checks: {},

  // pullReqs.id -> [{id, title}]
  patchsets: {},

  // An array similar to pullReqs, but for commits in main.
  mainCommits: [],

  // Action Runners.
  runners: [],

  // Workflows runs.
  workflowRuns: [],

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

  limitPostsubmit: 3,

  redrawPending: false,
};

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

function main() {
  m.route(document.body, "/cls", {
    "/cls": CLsPageRenderer,
    "/cls/:cl": CLsPageRenderer,
    "/jobs": JobsPageRenderer,
    "/jobs/:jobId": JobsPageRenderer,
  });

  setInterval(fetchPullRequests, 30000);
  fetchPullRequests();
  getMainCommits();
}

// -----------------------------------------------------------------------------
// 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}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.limitPostsubmit += 10;
              getMainCommits();
            },
          },
          "Load more",
        ),
      ),
    );

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

    let branchRows = [];
    for (
      let i = 0;
      i < Math.min(state.mainCommits.length, state.limitPostsubmit);
      i++
    ) {
      const commit = state.mainCommits[i];
      branchRows = branchRows.concat(renderCLRow(commit));
    }

    let clRows = [];
    for (const gerritCl of state.pullReqs) {
      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(
          "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=1]", "android"),
              m("td", "RC"),
            ),
            m(
              "tr",
              m("td", "gcc9"),
              m("td[colspan=7]", "clang"),
              m("td[colspan=1]", "ui"),
              m("td[colspan=1]", "clang-arm"),
              m("td[colspan=1]", "RC"),
            ),
            m(
              "tr#cls_header",
              JOB_TYPES.map((job) => m(`td#${job.id}`, job.label)),
            ),
          ),
          m(
            "tbody",
            postsubmitHeader,
            branchRows,
            postsubmitLoadMore,
            presubmitHeader,
            clRows,
          ),
        ),
        footer,
      ),
    ];
  },
};

function getLastUpdate(lastUpdate) {
  if (lastUpdate === undefined) return "";
  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.toISOString().substr(0, 10);
}

function renderCLRow(cl) {
  const expanded = !!state.expandCl[cl.id];
  const toggleExpand = () => {
    state.expandCl[cl.id] ^= 1;
    if (state.expandCl[cl.id]) {
      fetchAllPatchsetsForPr(cl);
    }
  };
  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=${cl.url}]`,
          `${cl.subject}`,
          cl.num && m("span.ps", `#${cl.num}`),
        ),
      ),
      m("td", cl.status),
      m("td", cl.owner),
      m("td", getLastUpdate(cl.lastUpdate)),
      JOB_TYPES.map((x) => renderClJobCell(cl.id, x.id)),
    ),
  );

  // If the usere clicked on the expand button, show also the other patchsets.
  if (state.expandCl[cl.id]) {
    for (const ps of state.patchsets[cl.id] ?? []) {
      rows.push(
        m(
          `tr.nested`,
          m("td", m(`a[href=${ps.url}]`, ps.title)),
          m("td", ""),
          m("td", ""),
          m("td", ""),
          JOB_TYPES.map((x) => renderClJobCell(ps.id, x.id)),
        ),
      );
    }
  }
  return rows;
}

function renderJobLink(jobStatus, url) {
  const ICON_MAP = {
    queued: "schedule",
    success: "check_circle",
    failure: "bug_report",
    skipped: "clear",
    in_progress: "hourglass_full",
    cancelled: "cancel",
    timed_out: "notification_important",
  };
  const icon = ICON_MAP[jobStatus] || "clear";
  return m(
    `a.${jobStatus}[href=${url}][title=${jobStatus}][target=_blank]`,
    m(`i.material-icons`, icon),
  );
}

function renderClJobCell(id, botName) {
  const check = (state.checks[id] ?? {})[botName];
  if (check === undefined) {
    return m("td.job", renderJobLink("unknown", ""));
  } else {
    return m("td.job", renderJobLink(check.status, check.url));
  }
}

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

  createWorkerTable: function () {
    const makeWokerRow = (runner) => {
      return m(
        "tr",
        { className: runner.status },
        m("td", runner.id),
        m("td", runner.name),
        m("td", runner.status),
      );
    };

    return m(
      "table.main-table",
      m(
        "thead",
        m("tr", m("td[colspan=3]", "Workers")),
        m("tr", m("td", "ID"), m("td", "Worker"), m("td", "Status")),
      ),
      m("tbody", state.runners.map(makeWokerRow)),
    );
  },

  createJobsTable: function () {
    const makeJobRow = (job) => {
      return m(
        "tr",
        { class: `workflow ${job.status}` },
        m("td", { colspan: 2 }),
        m("td", m("a", { href: job.html_url }, job.id)),
        m("td", job.status, job.conclusion && ` [${job.conclusion}]`),
        m("td", `${job.runner_id} (${job.runner_name})`),
        m("td", job.name),
        m("td", getLastUpdate(parseGhTime(job.created_at))),
        m("td", getLastUpdate(parseGhTime(job.updated_at))),
        m("td", getLastUpdate(parseGhTime(job.completed_at))),
      );
    };

    const makeWorkflowRow = (wkf) => {
      return [
        m(
          "tr",
          { class: `workflow ${wkf.status}` },
          m("td", m("a", { href: wkf.html_url }, wkf.id)),
          m("td", wkf.event),
          m("td", ""),
          m("td", wkf.status, wkf.conclusion && ` [${wkf.conclusion}]`),
          m("td", wkf.actor.login),
          m("td", wkf.display_title),
          m("td", getLastUpdate(parseGhTime(wkf.created_at))),
          m("td", getLastUpdate(parseGhTime(wkf.updated_at))),
          m("td", ""),
        ),
      ].concat(wkf.jobs.map(makeJobRow));
    };

    return m(
      "table.main-table",
      m(
        "thead",
        m("tr", m("td[colspan=9]", "Workflow runs")),
        m(
          "tr",
          m("td", "WKF ID"),
          m("td", "Trigger"),
          m("td", "Job ID"),
          m("td", "Status"),
          m("td", "Author"),
          m("td", "Title"),
          m("td", "Created"),
          m("td", "Updated"),
          m("td", "Completed"),
        ),
      ),
      m(
        "tbody",
        state.workflowRuns.sort((a, b) => b.id - a.id).map(makeWorkflowRow),
      ),
    );
  },

  view: function (vnode) {
    return [
      renderHeader(),
      m(
        "main",
        m(".jobs-list", this.createWorkerTable(), this.createJobsTable()),
      ),
    ];
  },
};

// -----------------------------------------------------------------------------
// Business logic (handles fetching from GitHub).
// -----------------------------------------------------------------------------

function parseGhTime(str) {
  if (str === null || str === undefined) return undefined;
  // Gerrit timestamps are UTC (as per public docs) but obviously they are not
  // encoded in ISO format.
  return new Date(str);
}

async function getMainCommits() {
  console.log("Fetching commits from GitHub");
  const uri = "/gh/commits/main";
  const response = await fetch(uri);
  const commits = JSON.parse(await response.text());
  state.mainCommits = [];
  for (let i = 0; i < Math.min(commits.length, state.limitPostsubmit); i++) {
    const c = commits[i];
    const id = `main/${c.sha}`;
    const pr = {
      id: id,
      url: c.html_url,
      subject: c.commit.message.split("\n")[0],
      revHash: c.sha,
      lastUpdate: parseGhTime(c.commit.committer.date),
      owner: c.author.login,
    };
    state.mainCommits.push(pr);
    fetchChecksForPR(pr.id, pr.revHash);
  }
  console.log(`Got ${state.pullReqs.length} commits`);
  scheduleRedraw();
}

// Fetches the list of CLs from gerrit and updates the state.
async function fetchPullRequests() {
  console.log("Fetching PRs from GitHub");
  const uri = "/gh/pulls";
  const response = await fetch(uri);
  state.pullReqs = [];
  if (response.status !== 200) {
    setTimeout(fetchPullRequests, 3000); // Retry.
    return;
  }

  const pulls = JSON.parse(await response.text());
  state.pullReqs = [];
  for (const p of pulls) {
    const id = `${p.number}/${p.head.sha}`;
    const pr = {
      id: id,
      url: p.html_url,
      subject: p.title,
      status: p.state,
      num: p.number,
      revHash: p.head.sha,
      lastUpdate: parseGhTime(p.updated_at),
      owner: p.user.login,
    };
    state.pullReqs.push(pr);
    fetchChecksForPR(pr.id, pr.revHash);
  }
  console.log(`Got ${state.pullReqs.length} PRs`);
  scheduleRedraw();
}

async function fetchChecksForPR(id, commitHash) {
  state.checks[id] ??= {};
  const prChecks = state.checks[id];
  const response = await fetch(`/gh/checks/${commitHash}`);
  const json = JSON.parse(await response.text());
  for (const check of json.check_runs) {
    // status is either: 'queued', 'in_progress', 'success', 'failure', or other
    // values we don't really bother supporting.
    let status = check.status;
    if (status === "completed") {
      status = check.conclusion;
    }

    // Extract the job ID from the long concatenated Github Actions string.
    // The input can be either
    // linux / linux (gcc9-x86_64-release, is_debug=false  (when using matrix)
    // or just
    // bazel / bazel
    // We want in output: 'linux/gcc9-x86_64-release' or 'bazel'.
    const m = check.name.match(
      /^([\w-]+)\s*\/\s*[\w-]+(?:\s*\(\s*([^,\s)]+))?/,
    );
    if (!m) continue;
    const name = m[1] + (m[2] ? `/${m[2]}` : "");
    prChecks[name] = {
      status: status,
      url: check.details_url,
    };
  }
  scheduleRedraw();
}

async function fetchAllPatchsetsForPr(pr) {
  const patchsets = (state.patchsets[pr.id] = []);
  const response = await fetch(`/gh/patchsets/${pr.num}`);
  const json = JSON.parse(await response.text());
  for (const commit of json) {
    const psId = `${pr.num}/${commit.sha}`;
    patchsets.push({
      id: psId,
      url: `${pr.url}/commits/${commit.sha}`,
      sha: commit.sha,
      title: commit.commit.message.split("\n")[0],
    });
    fetchChecksForPR(psId, commit.sha);
  }
  scheduleRedraw();
}

async function fetchWorkers() {
  const uri = "/gh/runners";
  const response = await fetch(uri);
  state.runners = JSON.parse(await response.text()).runners;
  scheduleRedraw();
}

async function fetchWorkflows() {
  const uri = "/gh/workflows";
  const response = await fetch(uri);
  const resp = await response.text();
  state.workflowRuns = JSON.parse(resp);
  scheduleRedraw();
}

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

main();
