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

"use strict";

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

/**
 * A class that mocks the IP Protection panel.
 */
class FakeIPProtectionPanelElement {
  constructor() {
    this.state = {
      isProtectionEnabled: false,
    };
    this.isConnected = false;
    this.ownerDocument = {
      removeEventListener() {
        /* NOOP */
      },
    };
  }

  requestUpdate() {
    /* NOOP */
  }

  remove() {
    /* NOOP */
  }
}

/**
 * A class that mocks the IP Protection panel.
 */
class FakeIPProtectionPanelView {
  constructor() {
    this.state = "open";
    this.ownerDocument = {
      removeEventListener() {
        /* NOOP */
      },
    };
  }

  hidePopup() {
    /* NOOP */
  }
}

add_setup(async function () {
  // FxAccountsStorage.sys.mjs requires a profile directory.
  do_get_profile();
  await putServerInRemoteSettings();

  await IPProtectionService.init();

  registerCleanupFunction(async () => {
    IPProtectionService.uninit();
  });
});

/**
 * Tests that we can set a state and pass it to a fake element.
 */
add_task(async function test_setState() {
  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();

  ipProtectionPanel.state = {};
  fakeElement.state = {};

  ipProtectionPanel.setState({
    foo: "bar",
  });

  Assert.deepEqual(
    ipProtectionPanel.state,
    { foo: "bar" },
    "The state should be set on the IPProtectionPanel instance"
  );

  Assert.deepEqual(
    fakeElement.state,
    {},
    "The state should not be set on the fake element, as it is not connected"
  );

  fakeElement.isConnected = true;

  ipProtectionPanel.setState({
    isFoo: true,
  });

  Assert.deepEqual(
    ipProtectionPanel.state,
    { foo: "bar", isFoo: true },
    "The state should be set on the IPProtectionPanel instance"
  );

  Assert.deepEqual(
    fakeElement.state,
    { foo: "bar", isFoo: true },
    "The state should be set on the fake element"
  );
});

/**
 * Tests that the whole state will be updated when calling updateState directly.
 */
add_task(async function test_updateState() {
  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();

  ipProtectionPanel.state = {};
  fakeElement.state = {};

  ipProtectionPanel.setState({
    foo: "bar",
  });

  Assert.deepEqual(
    fakeElement.state,
    {},
    "The state should not be set on the fake element, as it is not connected"
  );

  fakeElement.isConnected = true;
  ipProtectionPanel.updateState();

  Assert.deepEqual(
    fakeElement.state,
    { foo: "bar" },
    "The state should be set on the fake element"
  );
});

/**
 * Tests that we can set a state on multiple fake elements.
 */
add_task(async function test_updateComponentState() {
  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElementA = new FakeIPProtectionPanelElement();
  let fakeElementB = new FakeIPProtectionPanelElement();

  ipProtectionPanel.panel = new FakeIPProtectionPanelView();
  ipProtectionPanel.state = {
    foo: "bar",
  };
  fakeElementA.state = {};
  fakeElementA.isConnected = true;
  fakeElementB.state = {};
  fakeElementB.isConnected = true;

  ipProtectionPanel.updateComponentState(fakeElementA);

  Assert.ok(
    ipProtectionPanel.components.has(fakeElementA),
    "The fake element A should be in the components set"
  );

  Assert.deepEqual(
    fakeElementA.state,
    { foo: "bar" },
    "The state should be set on the fake element A"
  );

  Assert.deepEqual(
    fakeElementB.state,
    {},
    "The state should not be set on the fake element B"
  );

  ipProtectionPanel.updateComponentState(fakeElementB);

  Assert.ok(
    ipProtectionPanel.components.has(fakeElementB),
    "The fake element B should be in the components set"
  );

  Assert.deepEqual(
    fakeElementB.state,
    { foo: "bar" },
    "The state should be set on the fake element B"
  );

  // Updating the state now should update both elements.
  ipProtectionPanel.setState({
    isFoo: true,
  });

  Assert.deepEqual(
    fakeElementA.state,
    { foo: "bar", isFoo: true },
    "The state should be set on the fake element A"
  );

  Assert.deepEqual(
    fakeElementB.state,
    { foo: "bar", isFoo: true },
    "The state should be set on the fake element B"
  );
});

/**
 * Tests that IPProtectionService ready state event updates the state.
 */
add_task(async function test_IPProtectionPanel_signedIn() {
  IPPDummyAuthProvider.simulateSignIn(true);
  IPPDummyAuthProvider.setEntitlement(createTestEntitlement(), {
    silent: true,
  });
  IPPDummyAuthProvider.setGetEntitlementResponse({
    entitlement: createTestEntitlement(),
  });

  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();
  fakeElement.isConnected = true;

  let signedInEventPromise = waitForEvent(
    IPProtectionService,
    "IPProtectionService:StateChanged",
    () => IPProtectionService.state === IPProtectionStates.READY
  );
  IPProtectionService.updateState();

  await signedInEventPromise;

  Assert.equal(
    ipProtectionPanel.state.unauthenticated,
    false,
    "unauthenticated should be false in the IPProtectionPanel state"
  );

  Assert.equal(
    fakeElement.state.unauthenticated,
    false,
    "unauthenticated should be false in the fake elements state"
  );
});

/**
 * Tests that IPProtectionService unauthenticated state event updates the state.
 */
add_task(async function test_IPProtectionPanel_signedOut() {
  IPPDummyAuthProvider.simulateSignIn(false);

  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();
  fakeElement.isConnected = true;

  IPProtectionService.setState(IPProtectionStates.READY);
  let signedOutEventPromise = waitForEvent(
    IPProtectionService,
    "IPProtectionService:StateChanged",
    () => IPProtectionService.state === IPProtectionStates.UNAUTHENTICATED
  );
  IPProtectionService.updateState();

  await signedOutEventPromise;

  Assert.equal(
    ipProtectionPanel.state.unauthenticated,
    true,
    "unauthenticated should be true in the IPProtectionPanel state"
  );

  Assert.equal(
    fakeElement.state.unauthenticated,
    true,
    "unauthenticated should be true in the fake elements state"
  );
});

/**
 * Tests that start and stopping the IPProtectionService updates the state.
 */
add_task(async function test_IPProtectionPanel_started_stopped() {
  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();
  fakeElement.isConnected = true;

  IPPDummyAuthProvider.simulateSignIn(true);
  IPPDummyAuthProvider.setEntitlement(createTestEntitlement(), {
    silent: true,
  });
  IPPDummyAuthProvider.setGetEntitlementResponse({
    entitlement: createTestEntitlement(),
  });
  IPPDummyAuthProvider.setProxyPass({
    status: 200,
    error: undefined,
    pass: new ProxyPass(createProxyPassToken()),
    usage: new ProxyUsage(
      "5368709120",
      "4294967296",
      "2026-02-01T00:00:00.000Z"
    ),
  });

  IPProtectionService.updateState();

  Assert.equal(
    IPProtectionService.state,
    IPProtectionStates.READY,
    "IP Protection service should be in READY state before starting"
  );

  let startedEventPromise = waitForEvent(
    IPPProxyManager,
    "IPPProxyManager:StateChanged",
    () => IPPProxyManager.state === IPPProxyStates.ACTIVE
  );

  IPPProxyManager.start();

  await startedEventPromise;

  Assert.equal(
    ipProtectionPanel.state.isProtectionEnabled,
    true,
    "isProtectionEnabled should be true in the IPProtectionPanel state"
  );

  Assert.equal(
    fakeElement.state.isProtectionEnabled,
    true,
    "isProtectionEnabled should be true in the fake elements state"
  );

  let stoppedEventPromise = waitForEvent(
    IPPProxyManager,
    "IPPProxyManager:StateChanged",
    () => IPPProxyManager.state !== IPPProxyStates.ACTIVE
  );

  await IPPProxyManager.stop();

  await stoppedEventPromise;

  Assert.equal(
    ipProtectionPanel.state.isProtectionEnabled,
    false,
    "isProtectionEnabled should be false in the IPProtectionPanel state"
  );

  Assert.equal(
    fakeElement.state.isProtectionEnabled,
    false,
    "isProtectionEnabled should be false in the fake elements state"
  );
});

/**
 * Tests that locationsList is populated from IPProtectionServerlist and
 * kept in sync with IPProtectionServerlist:ListChanged events.
 */
add_task(async function test_IPProtectionPanel_locationsList() {
  await IPProtectionServerlist.maybeFetchList(true);

  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();
  fakeElement.isConnected = true;

  Assert.deepEqual(
    ipProtectionPanel.state.locationsList,
    IPProtectionServerlist.countries,
    "locationsList should be set to IPProtectionServerlist.countries at construction"
  );
  Assert.ok(
    ipProtectionPanel.state.locationsList.some(c => c.code === "US"),
    "locationsList should include the seeded US country"
  );

  IPProtectionServerlist.dispatchEvent(
    new Event("IPProtectionServerlist:ListChanged")
  );

  Assert.deepEqual(
    ipProtectionPanel.state.locationsList,
    IPProtectionServerlist.countries,
    "locationsList should be refreshed when ListChanged fires"
  );
  Assert.deepEqual(
    fakeElement.state.locationsList,
    IPProtectionServerlist.countries,
    "locationsList should propagate to the connected element"
  );

  ipProtectionPanel.uninit();
});

/**
 * Tests that UsageChanged events with BigInt(0) remaining bandwidth
 * are processed correctly (not treated as falsy and skipped).
 *
 * Regression test: BigInt(0) is falsy in JavaScript, so a guard like
 * `!usage.remaining` would incorrectly bail out when remaining is exactly 0.
 */
add_task(async function test_IPProtectionPanel_usage_zero_remaining() {
  setupStubs();

  Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true);

  let ipProtectionPanel = new IPProtectionPanel();
  let fakeElement = new FakeIPProtectionPanelElement();
  ipProtectionPanel.components.add(fakeElement);
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();
  fakeElement.isConnected = true;

  Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold");

  // Create a usage object with remaining = 0 (BigInt)
  const usage = new ProxyUsage("5368709120", "0", "3026-02-01T00:00:00.000Z");
  Assert.equal(usage.remaining, BigInt(0), "remaining should be BigInt(0)");

  // Dispatch a UsageChanged event with zero remaining bandwidth
  IPPProxyManager.dispatchEvent(
    new CustomEvent("IPPProxyManager:UsageChanged", {
      bubbles: true,
      composed: true,
      detail: { usage },
    })
  );

  // With 0 bytes remaining out of 5GB, remainingPercent = 0.
  // This is <= THIRD_THRESHOLD (0.1), so threshold should be set to 90.
  const threshold = Services.prefs.getIntPref(
    "browser.ipProtection.bandwidthThreshold",
    0
  );
  Assert.equal(
    threshold,
    100,
    "bandwidthThreshold pref should be 100 when remaining bandwidth is zero"
  );

  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold");
  Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled");
});

/**
 * Tests that opening the panel while paused re-checks usage.
 */
add_task(async function test_showing_refreshes_usage_when_paused() {
  let ipProtectionPanel = new IPProtectionPanel();
  ipProtectionPanel.panel = new FakeIPProtectionPanelView();

  let refreshUsageStub = sinon.stub(IPPProxyManager, "refreshUsage").resolves();

  ipProtectionPanel.state.paused = false;
  ipProtectionPanel.showing(ipProtectionPanel.panel);

  Assert.ok(
    refreshUsageStub.notCalled,
    "refreshUsage should not be called when opening the panel while not paused"
  );

  ipProtectionPanel.state.paused = true;
  ipProtectionPanel.showing(ipProtectionPanel.panel);
  Assert.ok(
    refreshUsageStub.calledOnce,
    "refreshUsage should be called when opening the panel while paused"
  );

  refreshUsageStub.restore();
  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref("browser.ipProtection.everOpenedPanel");
  Services.prefs.clearUserPref("browser.ipProtection.openedPanelWithLocation");
});

/**
 * Tests that showLocationButtonBadge is true when the dismissed pref is not set.
 */
add_task(async function test_location_badge_initial_state_pref_unset() {
  Services.prefs.clearUserPref(
    "browser.ipProtection.locationButtonBadgeDismissed"
  );

  let ipProtectionPanel = new IPProtectionPanel();

  Assert.equal(
    ipProtectionPanel.state.showLocationButtonBadge,
    true,
    "showLocationButtonBadge should be true when pref is not set"
  );

  ipProtectionPanel.uninit();
});

/**
 * Tests that showLocationButtonBadge is false when the dismissed pref is set to true.
 */
add_task(async function test_location_badge_initial_state_pref_set() {
  Services.prefs.setBoolPref(
    "browser.ipProtection.locationButtonBadgeDismissed",
    true
  );

  let ipProtectionPanel = new IPProtectionPanel();

  Assert.equal(
    ipProtectionPanel.state.showLocationButtonBadge,
    false,
    "showLocationButtonBadge should be false when pref is set to true"
  );

  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref(
    "browser.ipProtection.locationButtonBadgeDismissed"
  );
});

function dispatchUsageEvent(max, remaining) {
  IPPProxyManager.dispatchEvent(
    new CustomEvent("IPPProxyManager:UsageChanged", {
      bubbles: true,
      composed: true,
      detail: {
        usage: new ProxyUsage(
          String(max),
          String(remaining),
          "3026-03-01T00:00:00.000Z"
        ),
      },
    })
  );
}

/**
 * Tests that bandwidth threshold telemetry events fire at 50%, 75%, and 90%.
 */
add_task(async function test_bandwidth_used_threshold_events() {
  Services.fog.initializeFOG();
  Services.fog.testResetFOG();

  Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true);

  let ipProtectionPanel = new IPProtectionPanel();

  // 40% used (60% remaining) - no thresholds crossed
  dispatchUsageEvent(1000000, 600000);
  Assert.equal(
    Glean.ipprotection.bandwidthUsedThreshold.testGetValue(),
    null,
    "No threshold event should fire at 40% used"
  );

  // 55% used (45% remaining) - crosses 50%
  dispatchUsageEvent(1000000, 450000);
  let events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(events.length, 1, "One threshold event should fire at 55% used");
  Assert.equal(events[0].extra.percentage, "50", "Should report 50% threshold");

  // 80% used (20% remaining) - crosses 75%
  dispatchUsageEvent(1000000, 200000);
  events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(events.length, 2, "Two threshold events total at 80% used");
  Assert.equal(events[1].extra.percentage, "75", "Should report 75% threshold");

  // 95% used (5% remaining) - crosses 90%
  dispatchUsageEvent(1000000, 50000);
  events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(events.length, 3, "Three threshold events total at 95% used");
  Assert.equal(events[2].extra.percentage, "90", "Should report 90% threshold");

  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold");
  Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled");
  Services.fog.testResetFOG();
});

/**
 * Tests that threshold events are not re-fired within the same usage period.
 */
add_task(async function test_bandwidth_thresholds_not_repeated_same_period() {
  Services.fog.testResetFOG();

  Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true);

  let ipProtectionPanel = new IPProtectionPanel();

  // Cross 50% threshold
  dispatchUsageEvent(1000000, 400000);
  let events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(events.length, 1, "One event after first call at 60% used");

  // Same usage dispatched again - should not re-fire
  dispatchUsageEvent(1000000, 400000);
  events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(
    events.length,
    1,
    "No additional event when threshold already reported"
  );

  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold");
  Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled");
  Services.fog.testResetFOG();
});

/**
 * Tests that thresholds reset when a new usage period begins.
 */
add_task(async function test_bandwidth_thresholds_reset_on_new_period() {
  Services.fog.testResetFOG();

  Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true);

  let ipProtectionPanel = new IPProtectionPanel();

  // Cross 50% in the current period
  dispatchUsageEvent(1000000, 400000);
  let events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(events.length, 1, "One event in current period");
  Assert.equal(events[0].extra.percentage, "50");

  // Simulate a period reset by returning to full bandwidth (threshold drops to 0)
  dispatchUsageEvent(1000000, 1000000);
  Services.fog.testResetFOG();

  // 50% should fire again since the threshold pref was reset to 0
  dispatchUsageEvent(1000000, 400000);
  events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue();
  Assert.equal(events.length, 1, "50% fires again after period reset");
  Assert.equal(events[0].extra.percentage, "50");

  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold");
  Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled");
  Services.fog.testResetFOG();
});

/**
 * Tests that an unlimited UsageChanged event clears the bandwidth tracking
 * prefs and resets the bandwidthUsage state.
 */
add_task(async function test_bandwidth_unlimited_usage_clears_tracking() {
  Services.fog.initializeFOG();
  Services.fog.testResetFOG();

  Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true);
  Services.prefs.setIntPref("browser.ipProtection.bandwidthThreshold", 75);
  Services.prefs.setStringPref(
    "browser.ipProtection.bandwidthResetDate",
    "3026-03-01T00:00:00.000Z"
  );

  let ipProtectionPanel = new IPProtectionPanel();
  ipProtectionPanel.setState({
    bandwidthUsage: { max: 1000000, remaining: 200000, reset: null },
  });

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

  Assert.strictEqual(
    ipProtectionPanel.state.bandwidthUsage,
    null,
    "bandwidthUsage state should be reset for unlimited usage"
  );
  Assert.ok(
    !Services.prefs.prefHasUserValue("browser.ipProtection.bandwidthThreshold"),
    "bandwidthThreshold pref should be cleared for unlimited usage"
  );
  Assert.ok(
    !Services.prefs.prefHasUserValue("browser.ipProtection.bandwidthResetDate"),
    "bandwidthResetDate pref should be cleared for unlimited usage"
  );
  Assert.equal(
    Glean.ipprotection.bandwidthUsedThreshold.testGetValue(),
    null,
    "No threshold telemetry should be recorded for unlimited usage"
  );

  ipProtectionPanel.uninit();
  Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled");
  Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold");
  Services.prefs.clearUserPref("browser.ipProtection.bandwidthResetDate");
  Services.fog.testResetFOG();
});
