/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";

const { Downloads } = ChromeUtils.importESModule(
  "resource://gre/modules/Downloads.sys.mjs"
);

const server = createHttpServer();
server.registerDirectory("/data/", do_get_file("data"));

const BASE = `http://localhost:${server.identity.primaryPort}/data`;
const TXT_FILE = "file_download.txt";
const TXT_URL = BASE + "/" + TXT_FILE;
const TXT_LEN = 46;
const HTML_FILE = "file_download.html";
const HTML_URL = BASE + "/" + HTML_FILE;
const HTML_LEN = 117;
const EMPTY_FILE = "empty_file_download.txt";
const EMPTY_URL = BASE + "/" + EMPTY_FILE;
const EMPTY_LEN = 0;
const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN

function backgroundScript() {
  let complete = new Map();

  function waitForComplete(id) {
    if (complete.has(id)) {
      return complete.get(id).promise;
    }

    let promise = new Promise(resolve => {
      complete.set(id, { resolve });
    });
    complete.get(id).promise = promise;
    return promise;
  }

  browser.downloads.onChanged.addListener(change => {
    if (change.state && change.state.current == "complete") {
      // Make sure we have a promise.
      waitForComplete(change.id);
      complete.get(change.id).resolve();
    }
  });

  browser.test.onMessage.addListener(async (msg, ...args) => {
    if (msg == "download.request") {
      try {
        let id = await browser.downloads.download(args[0]);
        browser.test.sendMessage("download.done", { status: "success", id });
      } catch (error) {
        browser.test.sendMessage("download.done", {
          status: "error",
          errmsg: error.message,
        });
      }
    } else if (msg == "search.request") {
      try {
        let downloads = await browser.downloads.search(args[0]);
        browser.test.sendMessage("search.done", {
          status: "success",
          downloads,
        });
      } catch (error) {
        browser.test.sendMessage("search.done", {
          status: "error",
          errmsg: error.message,
        });
      }
    } else if (msg == "waitForComplete.request") {
      await waitForComplete(args[0]);
      browser.test.sendMessage("waitForComplete.done");
    }
  });

  browser.test.sendMessage("ready");
}

async function clearDownloads() {
  let list = await Downloads.getList(Downloads.ALL);
  let downloads = await list.getAll();

  await Promise.all(downloads.map(download => list.remove(download)));

  return downloads;
}

add_task(async function test_search() {
  const nsIFile = Ci.nsIFile;
  let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
  downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
  info(`downloadDir ${downloadDir.path}`);

  function downloadPath(filename) {
    let path = downloadDir.clone();
    path.append(filename);
    return path.path;
  }

  Services.prefs.setIntPref("browser.download.folderList", 2);
  Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
  Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false);

  registerCleanupFunction(async () => {
    Services.prefs.clearUserPref("browser.download.folderList");
    Services.prefs.clearUserPref("browser.download.dir");
    Services.prefs.clearUserPref("privacy.reduceTimerPrecision");
    await cleanupDir(downloadDir);
    await clearDownloads();
  });

  await clearDownloads().then(downloads => {
    info(`removed ${downloads.length} pre-existing downloads from history`);
  });

  let extension = ExtensionTestUtils.loadExtension({
    background: backgroundScript,
    manifest: {
      permissions: ["downloads"],
    },
  });

  async function download(options) {
    extension.sendMessage("download.request", options);
    let result = await extension.awaitMessage("download.done");

    if (result.status == "success") {
      info(`wait for onChanged event to indicate ${result.id} is complete`);
      extension.sendMessage("waitForComplete.request", result.id);

      await extension.awaitMessage("waitForComplete.done");
    }

    return result;
  }

  function search(query) {
    extension.sendMessage("search.request", query);
    return extension.awaitMessage("search.done");
  }

  await extension.startup();
  await extension.awaitMessage("ready");

  // Do some downloads...
  const time1 = new Date();

  let downloadIds = {};
  let msg = await download({ url: TXT_URL });
  equal(msg.status, "success", "download() succeeded");
  downloadIds.txt1 = msg.id;

  const TXT_FILE2 = "NewFile.txt";
  msg = await download({ url: TXT_URL, filename: TXT_FILE2 });
  equal(msg.status, "success", "download() succeeded");
  downloadIds.txt2 = msg.id;

  msg = await download({ url: EMPTY_URL });
  equal(msg.status, "success", "download() succeeded");
  downloadIds.txt3 = msg.id;

  const time2 = new Date();

  msg = await download({ url: HTML_URL });
  equal(msg.status, "success", "download() succeeded");
  downloadIds.html1 = msg.id;

  const HTML_FILE2 = "renamed.html";
  msg = await download({ url: HTML_URL, filename: HTML_FILE2 });
  equal(msg.status, "success", "download() succeeded");
  downloadIds.html2 = msg.id;

  const time3 = new Date();

  // Search for each individual download and check
  // the corresponding DownloadItem.
  async function checkDownloadItem(id, expect) {
    let item = await search({ id });
    equal(item.status, "success", "search() succeeded");
    equal(item.downloads.length, 1, "search() found exactly 1 download");

    Object.keys(expect).forEach(function (field) {
      equal(
        item.downloads[0][field],
        expect[field],
        `DownloadItem.${field} is correct"`
      );
    });
  }
  await checkDownloadItem(downloadIds.txt1, {
    url: TXT_URL,
    filename: downloadPath(TXT_FILE),
    mime: "text/plain",
    state: "complete",
    bytesReceived: TXT_LEN,
    totalBytes: TXT_LEN,
    fileSize: TXT_LEN,
    exists: true,
  });

  await checkDownloadItem(downloadIds.txt2, {
    url: TXT_URL,
    filename: downloadPath(TXT_FILE2),
    mime: "text/plain",
    state: "complete",
    bytesReceived: TXT_LEN,
    totalBytes: TXT_LEN,
    fileSize: TXT_LEN,
    exists: true,
  });

  await checkDownloadItem(downloadIds.txt3, {
    url: EMPTY_URL,
    filename: downloadPath(EMPTY_FILE),
    mime: "text/plain",
    state: "complete",
    bytesReceived: EMPTY_LEN,
    totalBytes: EMPTY_LEN,
    fileSize: EMPTY_LEN,
    exists: true,
  });

  await checkDownloadItem(downloadIds.html1, {
    url: HTML_URL,
    filename: downloadPath(HTML_FILE),
    mime: "text/html",
    state: "complete",
    bytesReceived: HTML_LEN,
    totalBytes: HTML_LEN,
    fileSize: HTML_LEN,
    exists: true,
  });

  await checkDownloadItem(downloadIds.html2, {
    url: HTML_URL,
    filename: downloadPath(HTML_FILE2),
    mime: "text/html",
    state: "complete",
    bytesReceived: HTML_LEN,
    totalBytes: HTML_LEN,
    fileSize: HTML_LEN,
    exists: true,
  });

  async function checkSearch(query, expected, description, exact) {
    let item = await search(query);
    equal(item.status, "success", "search() succeeded");
    equal(
      item.downloads.length,
      expected.length,
      `search() for ${description} found exactly ${expected.length} downloads`
    );

    let receivedIds = item.downloads.map(i => i.id);
    if (exact) {
      receivedIds.forEach((id, idx) => {
        equal(
          id,
          downloadIds[expected[idx]],
          `search() for ${description} returned ${expected[idx]} in position ${idx}`
        );
      });
    } else {
      Object.keys(downloadIds).forEach(key => {
        const id = downloadIds[key];
        const thisExpected = expected.includes(key);
        equal(
          receivedIds.includes(id),
          thisExpected,
          `search() for ${description} ${
            thisExpected ? "includes" : "does not include"
          } ${key}`
        );
      });
    }
  }

  // Check that search with an invalid id returns nothing.
  // NB: for now ids are not persistent and we start numbering them at 1
  //     so a sufficiently large number will be unused.
  const INVALID_ID = 1000;
  await checkSearch({ id: INVALID_ID }, [], "invalid id");

  // Check that search on url works.
  await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url");

  // Check that regexp on url works.
  const HTML_REGEX = "[download]{8}.html+$";
  await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp");

  // Check that compatible url+regexp works
  await checkSearch(
    { url: HTML_URL, urlRegex: HTML_REGEX },
    ["html1", "html2"],
    "compatible url+urlRegex"
  );

  // Check that incompatible url+regexp works
  await checkSearch(
    { url: TXT_URL, urlRegex: HTML_REGEX },
    [],
    "incompatible url+urlRegex"
  );

  // Check that search on filename works.
  await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename");

  // Check that regexp on filename works.
  await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex");

  // Check that compatible filename+regexp works
  await checkSearch(
    { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX },
    ["html1"],
    "compatible filename+filename regex"
  );

  // Check that incompatible filename+regexp works
  await checkSearch(
    { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX },
    [],
    "incompatible filename+filename regex"
  );

  // Check that simple positive search terms work.
  await checkSearch(
    { query: ["file_download"] },
    ["txt1", "txt2", "txt3", "html1", "html2"],
    "term file_download"
  );
  await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile");

  // Check that positive search terms work case-insensitive.
  await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe");

  // Check that negative search terms work.
  await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt");

  // Check that positive and negative search terms together work.
  await checkSearch(
    { query: ["html", "-renamed"] },
    ["html1"],
    "positive and negative terms"
  );

  async function checkSearchWithDate(query, expected, description) {
    const fields = Object.keys(query);
    if (fields.length != 1 || !(query[fields[0]] instanceof Date)) {
      throw new Error("checkSearchWithDate expects exactly one Date field");
    }
    const field = fields[0];
    const date = query[field];

    let newquery = {};

    // Check as a Date
    newquery[field] = date;
    await checkSearch(newquery, expected, `${description} as Date`);

    // Check as numeric milliseconds
    newquery[field] = date.valueOf();
    await checkSearch(newquery, expected, `${description} as numeric ms`);

    // Check as stringified milliseconds
    newquery[field] = date.valueOf().toString();
    await checkSearch(newquery, expected, `${description} as string ms`);

    // Check as ISO string
    newquery[field] = date.toISOString();
    await checkSearch(newquery, expected, `${description} as iso string`);
  }

  // Check startedBefore
  await checkSearchWithDate({ startedBefore: time1 }, [], "before time1");
  await checkSearchWithDate(
    { startedBefore: time2 },
    ["txt1", "txt2", "txt3"],
    "before time2"
  );
  await checkSearchWithDate(
    { startedBefore: time3 },
    ["txt1", "txt2", "txt3", "html1", "html2"],
    "before time3"
  );

  // Check startedAfter
  await checkSearchWithDate(
    { startedAfter: time1 },
    ["txt1", "txt2", "txt3", "html1", "html2"],
    "after time1"
  );
  await checkSearchWithDate(
    { startedAfter: time2 },
    ["html1", "html2"],
    "after time2"
  );
  await checkSearchWithDate({ startedAfter: time3 }, [], "after time3");

  // Check simple search on totalBytes
  await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes");
  await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes");

  // Check simple test on totalBytes{Greater,Less}
  // (NB: TXT_LEN < HTML_LEN < BIG_LEN)
  await checkSearch(
    { totalBytesGreater: 0 },
    ["txt1", "txt2", "html1", "html2"],
    "totalBytesGreater than 0"
  );
  await checkSearch(
    { totalBytesGreater: TXT_LEN },
    ["html1", "html2"],
    `totalBytesGreater than ${TXT_LEN}`
  );
  await checkSearch(
    { totalBytesGreater: HTML_LEN },
    [],
    `totalBytesGreater than ${HTML_LEN}`
  );
  await checkSearch(
    { totalBytesLess: TXT_LEN },
    ["txt3"],
    `totalBytesLess than ${TXT_LEN}`
  );
  await checkSearch(
    { totalBytesLess: HTML_LEN },
    ["txt1", "txt2", "txt3"],
    `totalBytesLess than ${HTML_LEN}`
  );
  await checkSearch(
    { totalBytesLess: BIG_LEN },
    ["txt1", "txt2", "txt3", "html1", "html2"],
    `totalBytesLess than ${BIG_LEN}`
  );

  // Bug 1503760 check if 0 byte files with no search query are returned.
  await checkSearch(
    {},
    ["txt1", "txt2", "txt3", "html1", "html2"],
    "totalBytesGreater than -1"
  );

  // Check good combinations of totalBytes*.
  await checkSearch(
    { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN },
    ["html1", "html2"],
    "totalBytes and totalBytesGreater"
  );
  await checkSearch(
    { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN },
    ["txt1", "txt2"],
    "totalBytes and totalBytesGreater"
  );
  await checkSearch(
    { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 },
    ["html1", "html2"],
    "totalBytes and totalBytesLess and totalBytesGreater"
  );

  // Check bad combination of totalBytes*.
  await checkSearch(
    { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN },
    [],
    "bad totalBytesLess, totalBytesGreater combination"
  );
  await checkSearch(
    { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN },
    [],
    "bad totalBytes, totalBytesGreater combination"
  );
  await checkSearch(
    { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN },
    [],
    "bad totalBytes, totalBytesLess combination"
  );

  // Check mime.
  await checkSearch(
    { mime: "text/plain" },
    ["txt1", "txt2", "txt3"],
    "mime text/plain"
  );
  await checkSearch(
    { mime: "text/html" },
    ["html1", "html2"],
    "mime text/htmlplain"
  );
  await checkSearch({ mime: "video/webm" }, [], "mime video/webm");

  // Check fileSize.
  await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize");
  await checkSearch({ fileSize: HTML_LEN }, ["html1", "html2"], "fileSize");

  // Fields like bytesReceived, paused, state, exists are meaningful
  // for downloads that are in progress but have not yet completed.
  // todo: add tests for these when we have better support for in-progress
  // downloads (e.g., after pause(), resume() and cancel() are implemented)

  // Check multiple query properties.
  // We could make this testing arbitrarily complicated...
  // We already tested combining fields with obvious interactions above
  // (e.g., filename and filenameRegex or startTime and startedBefore/After)
  // so now just throw as many fields as we can at a single search and
  // make sure a simple case still works.
  await checkSearch(
    {
      url: TXT_URL,
      urlRegex: "download",
      filename: downloadPath(TXT_FILE),
      filenameRegex: "download",
      query: ["download"],
      startedAfter: time1.valueOf().toString(),
      startedBefore: time2.valueOf().toString(),
      totalBytes: TXT_LEN,
      totalBytesGreater: 0,
      totalBytesLess: BIG_LEN,
      mime: "text/plain",
      fileSize: TXT_LEN,
    },
    ["txt1"],
    "many properties"
  );

  // Check simple orderBy (forward and backward).
  await checkSearch(
    { orderBy: ["startTime"] },
    ["txt1", "txt2", "txt3", "html1", "html2"],
    "orderBy startTime",
    true
  );
  await checkSearch(
    { orderBy: ["-startTime"] },
    ["html2", "html1", "txt3", "txt2", "txt1"],
    "orderBy -startTime",
    true
  );

  // Check orderBy with multiple fields.
  // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt
  // EMPTY_URL begins with e which precedes f
  await checkSearch(
    { orderBy: ["url", "-startTime"] },
    ["txt3", "html2", "html1", "txt2", "txt1"],
    "orderBy with multiple fields",
    true
  );

  // Check orderBy with limit.
  await checkSearch(
    { orderBy: ["url"], limit: 1 },
    ["txt3"],
    "orderBy with limit",
    true
  );

  // Check bad arguments.
  async function checkBadSearch(query, pattern, description) {
    let item = await search(query);
    equal(item.status, "error", "search() failed");
    ok(
      pattern.test(item.errmsg),
      `error message for ${description} was correct (${item.errmsg}).`
    );
  }

  await checkBadSearch(
    "myquery",
    /Incorrect argument type/,
    "query is not an object"
  );
  await checkBadSearch(
    { bogus: "boo" },
    /Unexpected property/,
    "query contains an unknown field"
  );
  await checkBadSearch(
    { query: "query string" },
    /Expected array/,
    "query.query is a string"
  );
  await checkBadSearch(
    { startedBefore: "i am not a time" },
    /Type error/,
    "query.startedBefore is not a valid time"
  );
  await checkBadSearch(
    { startedAfter: "i am not a time" },
    /Type error/,
    "query.startedAfter is not a valid time"
  );
  await checkBadSearch(
    { endedBefore: "i am not a time" },
    /Type error/,
    "query.endedBefore is not a valid time"
  );
  await checkBadSearch(
    { endedAfter: "i am not a time" },
    /Type error/,
    "query.endedAfter is not a valid time"
  );
  await checkBadSearch(
    { urlRegex: "[" },
    /Invalid urlRegex/,
    "query.urlRegexp is not a valid regular expression"
  );
  await checkBadSearch(
    { filenameRegex: "[" },
    /Invalid filenameRegex/,
    "query.filenameRegexp is not a valid regular expression"
  );
  await checkBadSearch(
    { orderBy: "startTime" },
    /Expected array/,
    "query.orderBy is not an array"
  );
  await checkBadSearch(
    { orderBy: ["bogus"] },
    /Invalid orderBy field/,
    "query.orderBy references a non-existent field"
  );

  await extension.unload();
});

// Test that downloads with totalBytes of -1 (ie, that have not yet started)
// work properly.  See bug 1519762 for details of a past regression in
// this area.
add_task(async function test_inprogress() {
  let resume,
    resumePromise = new Promise(resolve => {
      resume = resolve;
    });
  let hit = false;
  server.registerPathHandler("/data/slow", async (request, response) => {
    hit = true;
    response.processAsync();
    await resumePromise;
    response.setHeader("Content-type", "text/plain");
    response.write("");
    response.finish();
  });

  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      permissions: ["downloads"],
    },
    background() {
      browser.test.onMessage.addListener(async (msg, url) => {
        let id = await browser.downloads.download({ url });
        let full = await browser.downloads.search({ id });

        browser.test.assertEq(
          full.length,
          1,
          "Found new download in search results"
        );
        browser.test.assertEq(
          full[0].totalBytes,
          -1,
          "New download still has totalBytes == -1"
        );

        browser.downloads.onChanged.addListener(info => {
          if (info.id == id && info.state && info.state.current == "complete") {
            browser.test.notifyPass("done");
          }
        });

        browser.test.sendMessage("started");
      });
    },
  });

  await extension.startup();
  extension.sendMessage("go", `${BASE}/slow`);
  await extension.awaitMessage("started");
  resume();
  await extension.awaitFinish("done");
  await extension.unload();
  Assert.ok(hit, "slow path was actually hit");
});
