// ==UserScript==
// @name        Bad Apple!! Codeforces
// @namespace   https://github.com/meooow25
// @match       *://*.codeforces.com/profile/*
// @grant       GM_getResourceURL
// @grant       GM.getResourceURL
// @grant       GM.getResourceUrl
// @version     0.1.3
// @author      meooow
// @description Bad Apple!!
// @resource    bad_apple.mp3 https://raw.githubusercontent.com/meooow25/bad-apple-cf/c153a5888e16d426a9c6d2cf6558e70e06f22a5d/bad_apple.mp3
// @resource    frames.json https://raw.githubusercontent.com/meooow25/bad-apple-cf/c153a5888e16d426a9c6d2cf6558e70e06f22a5d/frames.json
// ==/UserScript==

(async function() {

  // Userscript manager compatibility
  const gm = GM || {};
  gm.getResourceURL = gm.getResourceURL || gm.getResourceUrl || (name => Promise.resolve(GM_getResourceURL(name)));

  function delay(ms) {
    return new Promise(r => setTimeout(r, ms));
  }

  // https://stackoverflow.com/a/31862081
  function forceRestyle(el) {
    window.getComputedStyle(el).opacity;
  }

  const graph = document.querySelector("#userActivityGraph");
  if (!graph) {
    // Not all profiles have activity graphs
    return;
  }

  function getSvg() {
    const svg = graph.querySelector("svg");
    const parentG = svg.querySelector("g");
    const cols = Array.from(parentG.querySelectorAll("g"));  // 52+1 cols of 7 days each
    const col53 = cols.pop();
    return { svg, cols, col53 };
  }

  const opacityAnimDur = 300;
  const fillAnimDur = 300;
  const style = document.createElement("style");
  style.textContent = `
    svg .opacity-anim {
      transition: opacity ${opacityAnimDur}ms linear;
    }
    svg .fill-anim {
      transition: fill ${fillAnimDur}ms linear;
    }
    .play-button {
      font-family: verdana, arial, sans-serif;
      font-size: 1em;
      margin-right: 1em;
    }
  `;
  document.head.appendChild(style);

  async function setupCells(forward = true) {
    const svgns = "http://www.w3.org/2000/svg";
    const cellOffset = 13;
    const { svg, cols, col53 } = getSvg();
    const col1 = cols[0], col52 = cols[51];

    function newCell(y) {
      const rect = document.createElementNS(svgns, "rect");
      rect.setAttribute("width", 11);
      rect.setAttribute("height", 11);
      rect.setAttribute("y", y);
      rect.setAttribute("fill", "#EBEDF0");
      rect.classList.add("opacity-anim", "new-cell");
      rect.setAttribute("opacity", 0.01);
      return rect;
    }

    if (forward) {
      // First column may have less than 7 days
      let cnt = col1.querySelectorAll("rect").length;
      let y = Number(col1.querySelector("rect").getAttribute("y"));
      for (let i = cnt; i < 7; i++) {
        y -= cellOffset;
        col1.insertBefore(newCell(y), col1.firstChild);
      }

      // 52nd column may have less than 7 days
      cnt = col52.querySelectorAll("rect").length;
      y = Number(col52.querySelector("rect:last-child").getAttribute("y"));
      for (let i = cnt; i < 7; i++) {
        y += cellOffset;
        col52.appendChild(newCell(y));
      }

      // Already 7 days, need 39 for 52x39 display
      y = 78; // Last y
      for (let i = 7; i < 39; i++) {
        y += cellOffset;
        for (const col of cols) {
          col.appendChild(newCell(y));
        }
      }

      // To hide col 53
      col53.classList.add("opacity-anim");
    }

    forceRestyle(svg);
    if (forward) {
      svg.querySelectorAll("rect.opacity-anim").forEach(cell => cell.setAttribute("opacity", 1));
      col53.setAttribute("opacity", 0);
    } else {
      svg.querySelectorAll("rect.opacity-anim").forEach(cell => cell.setAttribute("opacity", 0));
      col53.setAttribute("opacity", 1);
    }
    await delay(opacityAnimDur);

    if (!forward) {
      svg.querySelectorAll(".new-cell").forEach(cell => cell.parentNode.removeChild(cell));
      col53.classList.remove("opacity-anim");
    }
  }

  async function expandGraph(forward = true) {
    // Ideally would animate the viewBox but can't in a simple enough way
    // Expanding the containing div instead

    const { svg } = getSvg();

    function getHeight(el) {
      return Number(getComputedStyle(el).height.slice(0, -2)); // remove px suffix
    }

    const [cur, tgt] = forward ? [110, 526] : [526, 110];
    const pxPerUnit = getHeight(svg) / cur;
    const change = (tgt - cur) * pxPerUnit;

    const graphHeight = getHeight(graph);
    graph.style.height = graphHeight + "px";

    if (!forward) {
      svg.setAttribute("viewBox", "0 0 721 110");
    }

    forceRestyle(graph);
    graph.style.transition = "height 300ms ease-out";
    graph.style.height = graphHeight + change + "px";
    await delay(300);

    if (forward) {
      svg.setAttribute("viewBox", "0 0 721 526");
    }

    graph.style.transition = null;
    graph.style.height = null;
  }

  function disableSelects(forward = true) {
    // Disable selects to prevent the svg from being swapped out during animation
    const selects = document.querySelectorAll("._UserActivityFrame_selector select");
    for (const el of selects) {
      if (forward) {
        el.setAttribute("disabled", "");
      } else {
        el.removeAttribute("disabled");
      }
    }
  }

  async function setupAudio() {
    const audioUrl = await gm.getResourceURL("bad_apple.mp3");
    const source = document.createElement("source");
    source.src = audioUrl;
    source.type = "audio/mpeg";
    const audio = new Audio();
    audio.append(source);
    return audio;
  }

  async function setupVideo() {
    const frameDataUrl = await gm.getResourceURL("frames.json");
    let { fps, frames } = await fetch(frameDataUrl).then(r => r.json());

    frames = frames.map((frame) => {
      // Decode run length encoding
      const out = [];
      for (let i = 0; i < frame.length; i += 2) {
        for (let j = 0; j < frame[i + 1]; j++) {
          out.push(frame[i]);
        }
      }
      return out;
    });
    const frameDurationMs = 1000 / fps;

    // Frames use the 5-color palette used by CF
    const palette = [
      "#EBEDF0",
      "#91DA9E",
      "#40C463",
      "#30A14E",
      "#216E39",
    ];

    let svg;
    let cells;
    let originalFills;
    let playing;
    let drawnFrames;

    async function transitionCellFills(fun) {
      cells.forEach(cell => cell.classList.add("fill-anim"));
      forceRestyle(svg);
      fun();
      await delay(fillAnimDur);
      cells.forEach(cell => cell.classList.remove("fill-anim"));
    }

    async function beforePlay() {
      const { svg: svg_, cols } = getSvg();
      svg = svg_;

      cells = [];
      originalFills = [];
      for (const col of cols) {
        for (const cell of col.querySelectorAll("rect")) {
          cells.push(cell);
          originalFills.push([cell, cell.getAttribute("fill")]);
        }
      }

      await transitionCellFills(() => {
        cells.forEach(cell => cell.setAttribute("fill", palette[4]));
      });
    }

    function play() {
      playing = true;
      drawnFrames = 0;
      let start;
      let lastFrameIdx;
      let lastFrame = [];

      function drawFrame(now) {
        const currentFrameIdx = Math.min(Math.trunc((now - start) / frameDurationMs), frames.length - 1);
        if (currentFrameIdx === lastFrameIdx) {
          return;
        }
        const currentFrame = frames[currentFrameIdx];
        for (let i = 0; i < cells.length; i++) {
          if (currentFrame[i] !== lastFrame[i]) {
            cells[i].setAttribute("fill", palette[currentFrame[i]]);
          }
        }
        drawnFrames++;
        lastFrameIdx = currentFrameIdx;
        lastFrame = currentFrame;
      }

      function drawFrameWrapper(now) {
        if (!playing) {
          return;
        }
        if (start === undefined) {
          start = now;
        }
        drawFrame(now);
        window.requestAnimationFrame(drawFrameWrapper);
      }
      window.requestAnimationFrame(drawFrameWrapper);
    }

    async function stop() {
      playing = false;
      await delay(50); // Wait a bit in case the last frame is still being drawn
      console.log(`Animation stopped, drawn frames ${drawnFrames}/${frames.length}`);
      drawnFrames = null;
    }

    async function afterStop() {
      await transitionCellFills(() => {
        originalFills.forEach(([cell, fill]) => cell.setAttribute("fill", fill));
      });
      cells = null;
      originalFills = null;
    }

    return { beforePlay, play, stop, afterStop };
  }

  function setupButton() {
    const header = document.querySelector("._UserActivityFrame_header");
    const button = document.createElement("button");
    button.type = "button";
    button.textContent = "Bad Apple!!";
    button.classList.add("play-button");
    header.insertBefore(button, header.firstChild);
    return button;
  }

  const audio = await setupAudio();
  const video = await setupVideo();
  const button = setupButton();
  let state = "stopped";

  audio.addEventListener("playing", () => video.play());
  async function play() {
    state = "wait";
    disableSelects();
    await expandGraph();
    await setupCells();
    await video.beforePlay();
    audio.play();
    button.textContent = "Stop";
    state = "playing";
  }

  audio.addEventListener("pause", async () => {
    state = "wait";
    await video.stop();
    await video.afterStop();
    await setupCells(false);
    await expandGraph(false);
    disableSelects(false);
    button.textContent = "Play";
    state = "stopped";
  });

  button.addEventListener("click", async () => {
    if (state === "stopped") {
      await play();
    } else if (state === "playing") {
      audio.pause();
      audio.currentTime = 0;
    }
  });

})();