/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

/**
 * Tests for integrating interaction table data with interactions based
 * frecency. If the interaction data is considered "interesting", frecency
 * should experience a boost.
 *
 * Since we don't know the precise values of the score, the tests typically
 * have a pattern of caching a baseline without interactions and then checking
 * what happens when either interesting or un-interesting interactions are
 * inserted.
 */

"use strict";

// The Viewtime Threshold preference is stored in seconds. When recorded in
// places, the data is stored in milliseconds.
const VIEWTIME_THRESHOLD =
  Services.prefs.getIntPref(
    "places.frecency.pages.interactions.viewTimeSeconds"
  ) * 1000;
const MANY_KEYPRESSES_THRESHOLD = Services.prefs.getIntPref(
  "places.frecency.pages.interactions.manyKeypresses"
);

// The Viewtime Threshold for Keypresses preference is stored in seconds.
// When recorded in places, the data is stored in milliseconds.
const VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD =
  Services.prefs.getIntPref(
    "places.frecency.pages.interactions.viewTimeIfManyKeypressesSeconds"
  ) * 1000;

const SAMPLED_VISITS_THRESHOLD = Services.prefs.getIntPref(
  "places.frecency.pages.numSampledVisits"
);
const MAX_VISIT_GAP = Services.prefs.getIntPref(
  "places.frecency.pages.interactions.maxVisitGapSeconds"
);

async function insertIntoMozPlacesMetadata(
  place_id,
  {
    referrer_place_id = null,
    created_at = Date.now(),
    updated_at = Date.now(),
    total_view_time = 0,
    typing_time = 0,
    key_presses = 0,
    scrolling_time = 0,
    scrolling_distance = 0,
    document_type = 0,
    search_query_id = null,
  }
) {
  info("Inserting interaction into moz_places_metadata.");
  await PlacesUtils.withConnectionWrapper(
    "test_frecency_interactions::insertIntoMozPlacesMetadata",
    async db => {
      await db.execute(
        `
        INSERT INTO moz_places_metadata (
          place_id,
          referrer_place_id,
          created_at,
          updated_at,
          total_view_time,
          typing_time,
          key_presses,
          scrolling_time,
          scrolling_distance,
          document_type,
          search_query_id
        ) VALUES (
         :place_id,
         :referrer_place_id,
         :created_at,
         :updated_at,
         :total_view_time,
         :typing_time,
         :key_presses,
         :scrolling_time,
         :scrolling_distance,
         :document_type,
         :search_query_id
        )
      `,
        {
          place_id,
          referrer_place_id,
          created_at,
          updated_at,
          total_view_time,
          typing_time,
          key_presses,
          scrolling_time,
          scrolling_distance,
          document_type,
          search_query_id,
        }
      );
    }
  );
}

async function insertIntoMozPlaces({
  url,
  guid,
  url_hash,
  origin_id,
  frecency,
}) {
  await PlacesUtils.withConnectionWrapper(
    "test_frecency_interactions::insertIntoMozPlaces",
    async db => {
      await db.execute(
        `
        INSERT INTO moz_places (
          url,
          guid,
          url_hash,
          origin_id,
          frecency
        ) VALUES (
          :url,
          :guid,
          :url_hash,
          :origin_id,
          :frecency
        )
      `,
        {
          url,
          guid,
          url_hash,
          origin_id,
          frecency,
        }
      );
    }
  );
}

async function getPageWithUrl(url) {
  info(`Find ${url} in moz_places.`);
  let db = await PlacesUtils.promiseDBConnection();
  let rows = await db.execute(`SELECT * FROM moz_places WHERE url = :url`, {
    url,
  });
  Assert.equal(rows.length, 1, "Found one matching row in moz_places.");
  return rows.map(r => ({
    id: r.getResultByName("id"),
    url: r.getResultByName("url"),
    title: r.getResultByName("title"),
    frecency: r.getResultByName("frecency"),
    recalc_frecency: r.getResultByName("recalc_frecency"),
  }))[0];
}

add_setup(async function () {
  registerCleanupFunction(PlacesUtils.history.clear);
});

/**
 * Each of the interactions occur at the same time as the visit so they
 * are paired as one sample.
 */
add_task(async function one_visit_one_matching_interaction() {
  const TESTS = [
    {
      title: "View time is under threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD - 1,
      },
      expectIncrease: false,
    },
    {
      title: "View time exceeds threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD,
      },
      expectIncrease: true,
    },
    {
      title: "View time and key presses under threshold",
      interactionData: {
        total_view_time: VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD - 1,
        key_presses: MANY_KEYPRESSES_THRESHOLD,
      },
      expectIncrease: false,
    },
    {
      title: "View time and key presses under threshold",
      interactionData: {
        total_view_time: VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD,
        key_presses: MANY_KEYPRESSES_THRESHOLD - 1,
      },
      expectIncrease: false,
    },
    {
      title: "View time and key presses exceed threshold",
      interactionData: {
        total_view_time: VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD,
        key_presses: MANY_KEYPRESSES_THRESHOLD,
      },
      expectIncrease: true,
    },
  ];
  for (let test of TESTS) {
    info(`Running test: ${test.title}`);
    let url = "https://testdomain1.moz.org/";
    await PlacesTestUtils.addVisits([url]);

    let page = await getPageWithUrl(url);
    let oldFrecency = page.frecency;
    Assert.notEqual(
      oldFrecency,
      0,
      "Frecency with a visit but no interaction should not be zero."
    );

    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    Assert.equal(
      page.frecency,
      oldFrecency,
      `Frecency of ${url} should not have changed.`
    );

    await insertIntoMozPlacesMetadata(page.id, test.interactionData);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    page = await getPageWithUrl(url);
    if (test.expectIncrease) {
      Assert.greater(
        page.frecency,
        oldFrecency,
        `Frecency of ${url} should have increased.`
      );
    } else {
      Assert.equal(
        page.frecency,
        oldFrecency,
        `Frecency of ${url} should not have changed.`
      );
    }
    await PlacesUtils.history.clear();
  }
});

/**
 * Each of the interactions are one day before the visit so the interaction
 * and visit are separate samples.
 */
add_task(async function one_visit_one_non_matching_interaction() {
  const today = new Date();
  const yesterday = new Date();
  yesterday.setDate(today.getDate() - 1);
  const TESTS = [
    {
      title: "View time is under threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD - 1,
        created_at: yesterday.getTime(),
      },
      expectIncrease: false,
    },
    {
      title: "View time exceeds threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD,
        created_at: yesterday.getTime(),
      },
      expectIncrease: true,
    },
    {
      title: "View time and key presses under threshold",
      interactionData: {
        total_view_time: VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD - 1,
        key_presses: MANY_KEYPRESSES_THRESHOLD,
        created_at: yesterday.getTime(),
      },
      expectIncrease: false,
    },
    {
      title: "View time and key presses under threshold",
      interactionData: {
        total_view_time: VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD,
        key_presses: MANY_KEYPRESSES_THRESHOLD - 1,
        created_at: yesterday.getTime(),
      },
      expectIncrease: false,
    },
    {
      title: "View time and key presses exceed threshold",
      interactionData: {
        total_view_time: VIEWTIME_IF_MANY_KEYPRESSES_THRESHOLD,
        key_presses: MANY_KEYPRESSES_THRESHOLD,
        created_at: yesterday.getTime(),
      },
      expectIncrease: true,
    },
  ];
  for (let test of TESTS) {
    info(test.title);
    let url = "https://testdomain1.moz.org/";
    await PlacesTestUtils.addVisits([url]);

    let page = await getPageWithUrl(url);
    let oldFrecency = page.frecency;

    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    Assert.equal(
      page.frecency,
      oldFrecency,
      `Frecency of ${url} should not have changed.`
    );

    await insertIntoMozPlacesMetadata(page.id, test.interactionData);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

    page = await getPageWithUrl(url);
    if (test.expectIncrease) {
      Assert.greater(
        page.frecency,
        oldFrecency,
        `Frecency of ${url} should have increased.`
      );
    } else {
      Assert.equal(
        page.frecency,
        oldFrecency,
        `Frecency of ${url} should not have changed.`
      );
    }
    await PlacesUtils.history.clear();
  }
});

add_task(async function zero_visits_one_interaction() {
  const TESTS = [
    {
      title: "View time is under threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD - 1,
      },
    },
    {
      title: "View time is at threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD,
      },
    },
  ];

  for (let test of TESTS) {
    info(test.title);

    let url = "https://testdomain1.moz.org/";
    await insertIntoMozPlaces({
      url,
      url_hash: "1234567890",
      frecency: 0,
    });

    let page = await getPageWithUrl(url);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    Assert.equal(page.frecency, 0, "Frecency is zero");

    await insertIntoMozPlacesMetadata(page.id, test.interactionData);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

    page = await getPageWithUrl(url);
    Assert.equal(
      page.frecency,
      0,
      "Frecency remains 0 because frecency was at zero."
    );

    await PlacesUtils.history.clear();
  }
});

// When the sample threshold is reached, check the correct samples are
// used. This is done by inserting the same number of visits as the sample
// threshold and adding an interaction that doesn't belong to any of the visits
// before or after the visits.
add_task(async function max_samples_threshold() {
  const today = new Date();
  const yesterday = new Date();
  const twoDaysAgo = new Date();
  yesterday.setDate(today.getDate() - 1);
  twoDaysAgo.setDate(today.getDate() - 2);
  const TESTS = [
    {
      title: "Interactions are beyond sample threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD,
        created_at: twoDaysAgo.getTime(),
      },
      expectIncrease: false,
    },
    {
      title: "Interactions are within sample threshold",
      interactionData: {
        total_view_time: VIEWTIME_THRESHOLD,
        created_at: today.getTime(),
      },
      expectIncrease: true,
    },
  ];

  for (let test of TESTS) {
    info(test.title);
    let url = "https://testdomain1.moz.org/";
    for (let i = 0; i < SAMPLED_VISITS_THRESHOLD; ++i) {
      await PlacesTestUtils.addVisits([
        {
          url,
          visitDate: yesterday.getTime() * 1000,
        },
      ]);
    }

    let page = await getPageWithUrl(url);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    Assert.notEqual(page.frecency, 0, "Frecency is non-zero");

    let oldFrecency = page.frecency;
    await insertIntoMozPlacesMetadata(page.id, test.interactionData);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

    page = await getPageWithUrl(url);
    if (test.expectIncrease) {
      Assert.notEqual(page.frecency, 0, "Frecency is non zero");
      Assert.greater(
        page.frecency,
        oldFrecency,
        "Frecency greater than the old value."
      );
    } else {
      Assert.equal(page.frecency, oldFrecency, "Frecency didn't change");
    }

    await PlacesUtils.history.clear();
  }
});

// Verify that the number of interactions contributing to frecency
// is as expected. Insert an old visit to ensure the URL has a positive
// frecency. Ensure all interactions are treated as virtual visits by making
// them interesting and more recent than the oldest visit.
// Each virtual visit should increase the frecency score until
// the threshold is exceeded. Interactions are deliberately inserted
// sequentially in the past, as the date can affect the score.
add_task(async function max_interesting_interactions() {
  const today = new Date();

  // We deliberately set a visit that can't be grouped with an interaction.
  const yearAgo = new Date();
  yearAgo.setMonth(yearAgo.getMonth() - 12);
  let url = "https://testdomain1.moz.org/";
  await PlacesTestUtils.addVisits({
    url,
    visitDate: yearAgo.getTime() * 1000,
    transition: PlacesUtils.history.TRANSITIONS.LINK,
  });

  let page = await getPageWithUrl(url);
  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
  Assert.greater(page.frecency, 0, "Frecency is above zero");

  info("Insert interesting interactions.");
  for (let i = 0; i < SAMPLED_VISITS_THRESHOLD; ++i) {
    let pageBefore = await getPageWithUrl(url);
    await insertIntoMozPlacesMetadata(page.id, {
      total_view_time: VIEWTIME_THRESHOLD,
      created_at: today.getTime() - i,
    });
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    let pageAfter = await getPageWithUrl(url);
    Assert.greater(
      pageAfter.frecency,
      pageBefore.frecency,
      "Frecency has increased."
    );
  }

  info("Insert an interesting interaction beyond the threshold.");
  let pageBefore = await getPageWithUrl(url);
  await insertIntoMozPlacesMetadata(page.id, {
    total_view_time: VIEWTIME_THRESHOLD,
    // Choose a time that would make this below all other existing interactions.
    created_at: today.getTime() - 100,
  });
  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
  let pageAfter = await getPageWithUrl(url);
  Assert.equal(
    pageBefore.frecency,
    pageAfter.frecency,
    "Frecency is the same."
  );

  await PlacesUtils.history.clear();
});

add_task(async function temp_redirect_frecency() {
  const yesterday = new Date();

  let url1 = "http://testdomain1.moz.org/";
  let url2 = "https://testdomain2.moz.org/";
  let url3 = "https://testdomain2.moz.org/dashboard";

  const visitDate = yesterday * 1000;
  await PlacesTestUtils.addVisits([
    {
      url: url1,
      visitDate,
      transition: PlacesUtils.history.TRANSITIONS.TYPED,
    },
    {
      url: url2,
      visitDate,
      transition: PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT,
      referrer: Services.io.newURI(url1),
    },
    {
      url: url3,
      visitDate,
      transition: PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY,
      referrer: Services.io.newURI(url2),
    },
  ]);

  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

  let page1 = await getPageWithUrl(url1);
  Assert.notEqual(page1.frecency, 0, "Frecency is non-zero");

  let page2 = await getPageWithUrl(url2);
  Assert.notEqual(page2.frecency, 0, "Frecency is non-zero");

  let page3 = await getPageWithUrl(url3);
  Assert.notEqual(page3.frecency, 0, "Frecency is non-zero");

  info("For each visit, add an interesting interaction.");
  const created_at = yesterday.getTime();
  const updated_at = yesterday.getTime();
  await insertIntoMozPlacesMetadata(page1.id, {
    total_view_time: VIEWTIME_THRESHOLD,
    created_at,
    updated_at,
  });
  await insertIntoMozPlacesMetadata(page2.id, {
    total_view_time: VIEWTIME_THRESHOLD,
    created_at,
    updated_at,
  });
  await insertIntoMozPlacesMetadata(page3.id, {
    total_view_time: VIEWTIME_THRESHOLD,
    created_at,
    updated_at,
  });

  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

  let newPage1 = await getPageWithUrl(url1);
  Assert.equal(newPage1.frecency, page1.frecency, "Frecency is the same");

  let newPage2 = await getPageWithUrl(url2);
  Assert.equal(newPage2.frecency, page2.frecency, "Frecency is the same");

  let newPage3 = await getPageWithUrl(url3);
  Assert.greater(newPage3.frecency, page3.frecency, "Frecency has increased");

  await PlacesUtils.history.clear();
});

add_task(async function interaction_visit_gap() {
  let url = "https://testdomain1.moz.org/";
  let now = new Date();
  let maxVisitGapMs = MAX_VISIT_GAP * 1000;

  // Insert visits that match the number of sample threshold to avoid
  // interesting interactions becoming virtual visits.
  for (let i = 0; i < SAMPLED_VISITS_THRESHOLD; ++i) {
    await PlacesTestUtils.addVisits([
      {
        url,
        visitDate: now,
      },
    ]);
  }
  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

  let page = await getPageWithUrl(url);
  Assert.notEqual(page.frecency, 0, "Frecency is not zero");

  info("Add an interaction just below the visit gap.");
  await insertIntoMozPlacesMetadata(page.id, {
    total_view_time: VIEWTIME_THRESHOLD,
    created_at: now.getTime() - maxVisitGapMs - 1,
    updated_at: now.getTime() - maxVisitGapMs - 1,
  });
  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

  let updatedPage = await getPageWithUrl(url);
  Assert.equal(updatedPage.frecency, page.frecency, "Frecency didn't change.");

  info("Add an interaction at the visit gap.");
  await insertIntoMozPlacesMetadata(page.id, {
    total_view_time: VIEWTIME_THRESHOLD,
    created_at: now.getTime() - maxVisitGapMs,
    updated_at: now.getTime() - maxVisitGapMs,
  });
  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

  updatedPage = await getPageWithUrl(url);
  Assert.greater(updatedPage.frecency, page.frecency, "Frecency increased.");

  await PlacesUtils.history.clear();
});

add_task(async function old_visits_not_zero() {
  let testCases = [
    { daysOld: 365, description: "1 year old" },
    { daysOld: 1825, description: "5 years old" },
    { daysOld: 3650, description: "10 years old" },
  ];

  for (let testCase of testCases) {
    let oldDate = new Date();
    oldDate.setDate(oldDate.getDate() - testCase.daysOld);

    let url = `https://www.example-${testCase.daysOld}.com/`;
    await PlacesTestUtils.addVisits([
      {
        url,
        visitDate: oldDate.getTime() * 1000,
      },
    ]);
    await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();

    let page = await getPageWithUrl(url);
    Assert.notEqual(
      page.frecency,
      0,
      `Frecency should not be zero for ${testCase.description}`
    );

    Assert.greater(
      page.frecency,
      0,
      `Frecency should be positive for ${testCase.description}`
    );
  }

  await PlacesUtils.history.clear();
});

add_task(async function old_visits_new_interactions() {
  let url = "https://testdomain1.moz.org/";
  let oldDate = new Date();
  oldDate.setDate(oldDate.getDate() - 5000);

  await PlacesTestUtils.addVisits([
    { url, visitDate: oldDate.getTime() * 1000 },
  ]);

  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
  let page = await getPageWithUrl(url);
  let baselineFrecency = page.frecency;

  Assert.notEqual(baselineFrecency, 0, "Baseline frecency should not be zero.");
  Assert.greater(
    baselineFrecency,
    0,
    "Baseline frecency should be greater than 0."
  );

  info("Add a recent interesting interaction.");
  await insertIntoMozPlacesMetadata(page.id, {
    total_view_time: VIEWTIME_THRESHOLD * 2,
  });

  await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
  page = await getPageWithUrl(url);

  Assert.greater(
    page.frecency,
    baselineFrecency,
    "Recent interaction should boost frecency of page with really old visit."
  );

  await PlacesUtils.history.clear();
});
