/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const { IPPStartupCacheSingleton } = ChromeUtils.importESModule(
  "moz-src:///toolkit/components/ipprotection/IPPStartupCache.sys.mjs"
);

/**
 * Creates and initializes a new IPPStartupCacheSingleton instance.
 *
 * when used with `using` statement, it will be automatically uninitialized, when the test exists
 */
const makeCacheHandle = () => {
  let cache = new IPPStartupCacheSingleton();
  cache.init();
  return {
    cache,
    [Symbol.dispose]() {
      cache.uninit();
    },
  };
};

/**
 * Test the disabled cache
 */
add_task(async function test_IPPStartupCache_disabled() {
  // By default the cache is not active.
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", true);
  using cacheHandle = makeCacheHandle();

  Assert.ok(
    cacheHandle.cache.isStartupCompleted,
    "In XPCShell mode the cache is not active"
  );
});

/**
 * Test the enabled cache
 */
add_task(async function test_IPPStartupCache_enabled() {
  // By default the cache is not active.
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);

  // Default state is UNINITIALIZED
  {
    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );
    Assert.equal(
      cacheHandle.cache.state,
      IPProtectionStates.UNINITIALIZED,
      "The state is unitialized"
    );
  }

  // Fetch the cached state
  {
    Services.prefs.setCharPref(
      "browser.ipProtection.stateCache",
      IPProtectionStates.READY
    );

    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );
    Assert.equal(
      cacheHandle.cache.state,
      IPProtectionStates.READY,
      "The state is READY"
    );
  }

  // Invalid cache means UNINITIALIZED
  {
    Services.prefs.setCharPref(
      "browser.ipProtection.stateCache",
      "Hello World!"
    );

    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );
    Assert.equal(
      cacheHandle.cache.state,
      IPProtectionStates.UNINITIALIZED,
      "The state is unitialized"
    );
  }
});

/**
 * Cache the entitlement
 */
add_task(async function test_IPPStartupCache_enabled() {
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);

  // Default entitlement is null
  {
    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );
    Assert.equal(cacheHandle.cache.entitlement, null, "Null entitlement");
  }

  // Store and retrieve a valid entitlement
  {
    const originalEntitlement = new Entitlement({
      subscribed: true,
      uid: 12345,
      maxBytes: "1000000000",
    });

    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );

    cacheHandle.cache.storeEntitlement(originalEntitlement);

    const storedPref = Services.prefs.getCharPref(
      "browser.ipProtection.entitlementCache",
      ""
    );
    Assert.greater(storedPref.length, 0, "The cache is correctly stored");

    const hasUpgraded = Services.prefs.getBoolPref(
      "browser.ipProtection.hasUpgraded",
      false
    );
    Assert.equal(
      hasUpgraded,
      true,
      "hasUpgraded is true when subscribed is true"
    );

    const retrievedEntitlement = cacheHandle.cache.entitlement;
    Assert.notEqual(
      retrievedEntitlement,
      null,
      "Retrieved entitlement is not null"
    );

    for (const key of Object.keys(originalEntitlement)) {
      const expected = originalEntitlement[key];
      const actual = retrievedEntitlement[key];
      if (typeof expected === "bigint") {
        Assert.equal(actual.toString(), expected.toString(), `${key} matches`);
      } else {
        Assert.equal(actual, expected, `${key} matches`);
      }
    }
  }

  // Store and retrieve an entitlement with subscribed: false
  {
    const unsubscribedEntitlement = new Entitlement({
      autostart: false,
      created_at: "2024-01-15T10:30:00.000Z",
      limited_bandwidth: true,
      location_controls: false,
      subscribed: false,
      uid: 12345,
      website_inclusion: false,
      maxBytes: "100000000",
    });

    using cacheHandle = makeCacheHandle();

    cacheHandle.cache.storeEntitlement(unsubscribedEntitlement);

    const hasUpgraded = Services.prefs.getBoolPref(
      "browser.ipProtection.hasUpgraded",
      true
    );
    Assert.equal(
      hasUpgraded,
      false,
      "hasUpgraded is false when subscribed is false"
    );
  }

  // Store null entitlement sets hasUpgraded to false
  {
    using cacheHandle = makeCacheHandle();

    cacheHandle.cache.storeEntitlement(null);

    const hasUpgraded = Services.prefs.getBoolPref(
      "browser.ipProtection.hasUpgraded",
      true
    );
    Assert.equal(
      hasUpgraded,
      false,
      "hasUpgraded is false when entitlement is null"
    );
  }

  // Invalid JSON returns null
  {
    Services.prefs.setCharPref(
      "browser.ipProtection.entitlementCache",
      '{"invalid json}}}}'
    );

    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );
    Assert.equal(
      cacheHandle.cache.entitlement,
      null,
      "Invalid JSON returns null"
    );
  }

  // Storing non-Entitlement objects throws
  {
    using cacheHandle = makeCacheHandle();

    Assert.ok(
      !cacheHandle.cache.isStartupCompleted,
      "In XPCShell mode the cache is active"
    );

    Assert.throws(
      () => cacheHandle.cache.storeEntitlement(42),
      /Error/,
      "Storing a number should throw"
    );

    Assert.throws(
      () => cacheHandle.cache.storeEntitlement({ a: 42 }),
      /Error/,
      "Storing arbitrary object should throw"
    );
  }

  Services.prefs.clearUserPref("browser.ipProtection.hasUpgraded");
  Services.prefs.clearUserPref("browser.ipProtection.entitlementCache");
});

add_task(async function test_IPPStartupCache_usageInfo_store_read() {
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);

  const originalUsage = new ProxyUsage(
    "1000000000",
    "750000000",
    "2026-02-01T00:00:00Z"
  );

  using cacheHandle = makeCacheHandle();

  cacheHandle.cache.storeUsageInfo(originalUsage);

  const storedPref = Services.prefs.getCharPref(
    "browser.ipProtection.usageCache",
    ""
  );
  Assert.greater(storedPref.length, 0, "usageInfo stored in pref");

  const retrievedUsage = cacheHandle.cache.usageInfo;
  Assert.notEqual(retrievedUsage, null, "Retrieved usageInfo is not null");

  Assert.equal(
    retrievedUsage.max.toString(),
    originalUsage.max.toString(),
    "max matches after store/read"
  );
  Assert.equal(
    retrievedUsage.remaining.toString(),
    originalUsage.remaining.toString(),
    "remaining matches after store/read"
  );
  Assert.equal(
    retrievedUsage.reset.toString(),
    originalUsage.reset.toString(),
    "reset matches after store/read"
  );
  Assert.equal(
    retrievedUsage.unlimited,
    originalUsage.unlimited,
    "unlimited matches after store/read"
  );

  cacheHandle.cache.storeUsageInfo(null);
  const clearedPref = Services.prefs.getCharPref(
    "browser.ipProtection.usageCache",
    ""
  );
  Assert.equal(clearedPref, "", "Null usageInfo clears pref");
  Assert.equal(
    cacheHandle.cache.usageInfo,
    null,
    "Reading cleared cache returns null"
  );

  Services.prefs.clearUserPref("browser.ipProtection.usageCache");
});

add_task(async function test_IPPStartupCache_unlimited_usage_store_read() {
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);

  const originalUsage = new ProxyUsage(null, null, null, true);

  using cacheHandle = makeCacheHandle();
  cacheHandle.cache.storeUsageInfo(originalUsage);

  const retrievedUsage = cacheHandle.cache.usageInfo;
  Assert.notEqual(retrievedUsage, null, "Retrieved usageInfo is not null");
  Assert.equal(retrievedUsage.unlimited, true, "unlimited round-trips");
  Assert.equal(retrievedUsage.max, null, "max is null for unlimited");
  Assert.equal(
    retrievedUsage.remaining,
    null,
    "remaining is null for unlimited"
  );
  Assert.equal(retrievedUsage.reset, null, "reset is null for unlimited");

  Services.prefs.clearUserPref("browser.ipProtection.usageCache");
});

add_task(async function test_IPPStartupCache_usageInfo_type_validation() {
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);

  using cacheHandle = makeCacheHandle();

  Assert.throws(
    () => cacheHandle.cache.storeUsageInfo(42),
    /Error/,
    "Storing number throws"
  );

  Assert.throws(
    () => cacheHandle.cache.storeUsageInfo({ max: "100", remaining: "50" }),
    /Error/,
    "Storing plain object throws"
  );

  Assert.throws(
    () => cacheHandle.cache.storeUsageInfo("string"),
    /Error/,
    "Storing string throws"
  );

  Assert.throws(
    () => cacheHandle.cache.storeUsageInfo([1, 2, 3]),
    /Error/,
    "Storing array throws"
  );
  Services.prefs.clearUserPref("browser.ipProtection.usageCache");
});

add_task(async function test_IPPStartupCache_usage_event_listener() {
  Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);

  await IPProtectionService.init();
  using cacheHandle = makeCacheHandle();

  Services.obs.notifyObservers(null, "sessionstore-windows-restored");
  await new Promise(resolve => executeSoon(resolve));

  Assert.ok(
    cacheHandle.cache.isStartupCompleted,
    "Startup should be completed after observer notification"
  );

  const testUsage = new ProxyUsage(
    "2000000000",
    "1500000000",
    "2026-02-15T00:00:00Z"
  );

  IPPProxyManager.dispatchEvent(
    new CustomEvent("IPPProxyManager:UsageChanged", {
      bubbles: true,
      composed: true,
      detail: { usage: testUsage },
    })
  );

  await new Promise(resolve => executeSoon(resolve));

  const storedPref = Services.prefs.getCharPref(
    "browser.ipProtection.usageCache",
    ""
  );
  Assert.greater(storedPref.length, 0, "Usage was stored after event dispatch");

  const retrievedUsage = cacheHandle.cache.usageInfo;
  Assert.equal(
    retrievedUsage.max.toString(),
    testUsage.max.toString(),
    "Stored usage max matches"
  );

  cacheHandle.cache.uninit();

  const newUsage = new ProxyUsage(
    "3000000000",
    "2000000000",
    "2026-03-15T00:00:00Z"
  );

  IPPProxyManager.dispatchEvent(
    new CustomEvent("IPPProxyManager:UsageChanged", {
      bubbles: true,
      composed: true,
      detail: { usage: newUsage },
    })
  );

  await new Promise(resolve => executeSoon(resolve));

  const unchangedUsage = cacheHandle.cache.usageInfo;
  Assert.equal(
    unchangedUsage.max.toString(),
    testUsage.max.toString(),
    "Usage unchanged after uninit"
  );

  Services.prefs.clearUserPref("browser.ipProtection.usageCache");
  Services.prefs.clearUserPref("browser.ipProtection.stateCache");
  IPProtectionService.uninit();
});
