import { jest } from "@jest/globals";
import rewire from "rewire";
import makeServiceWorkerEnv from "service-worker-mock";
import { parse as parseCookies } from "cookie";

import { specProxyProcessRequest, specProxyProcessResponse } from "./index";

// Workers supply `crypto` globally, but it's not present in our testing environment
// so let's fake it.
import * as crypto from 'crypto';

Object.defineProperty(globalThis, 'crypto', {
  value: {
    randomUUID: () => crypto.randomUUID()
  }
});

const TEST_URL = new URL("https://specprotected.com");
const TEST_SPEC_TRAFFIC_URL = new URL(
  "https://specprotected.com/spec_traffic/somewhere",
);

describe("processing", () => {
  beforeEach(() => {
    // temporary workaround until https://github.com/nodejs/node/pull/46615#issuecomment-1604484636
    // lands in node 18.x
    delete global.location;
    delete global.performance;
    delete global.navigator;
    delete global.self;

    Object.assign(global, makeServiceWorkerEnv());

    // blow away the Promise from Fetch API
    global.fetch = (v: any) => v;
    // create a fake version of the ExtendableEvent to use,
    // also doesn't use Promises
    global.simple_event = {
      waitUntil: jest.fn((v: any) => v),
      request: new Request("https://spectrust.com", {
        method: "GET",
        headers: {
          "X-Forwarded-For": "127.0.0.1",
          Host: "spectrust.com",
        },

        // ReadableStream is difficult to mock so can't test mirroring
        // which modifies the body object...
        // we also have to set it to null because service-worker-mock uses
        // a garbage implementation of this when it's not provided and disrupts
        // our use of the tee() function
        body: null,
      }),
    };
  });

  test("disabled proxy is noop", () => {
    expect(
      specProxyProcessRequest(global.simple_event, { disableSpecProxy: true }),
    ).toBe(global.simple_event.request);

    expect(
      specProxyProcessRequest(global.simple_event, { disableSpecProxy: false }),
    ).not.toBe(global.simple_event.request);
  });

  test("traffic split", () => {
    expect(
      specProxyProcessRequest(global.simple_event, { percentageOfIPs: 0 }),
    ).toBe(global.simple_event.request);

    expect(
      specProxyProcessRequest(global.simple_event, { percentageOfIPs: 100 }),
    ).not.toBe(global.simple_event.request);
  });

  test("proxy forks request in listening mode", () => {
    expect(specProxyProcessRequest(global.simple_event)).toEqual(
      global.simple_event.request,
    );

    expect(global.simple_event.waitUntil.mock.calls.length).toEqual(1);
  });

  // Note: libraries that make the request will change the Host header
  // for us. Cloudflare does this automatically, Fastly does this through
  // the use of the `backend` configuration property on the request.
  test("in listening mode, Host header of mirrored request is unchanged", () => {
    expect(specProxyProcessRequest(global.simple_event)).toEqual(
      global.simple_event.request,
    );

    let specProxyRequest = global.simple_event.waitUntil.mock.calls[0][0];
    console.log(specProxyRequest.headers, global.simple_event.request.headers);
    expect(specProxyRequest.headers.get("host")).toEqual(
      global.simple_event.request.headers.get("host"),
    );
  });

  test("proxy replaces request in inline mode", () => {
    let request = specProxyProcessRequest(global.simple_event, {
      inlineMode: true,
    });
    expect(request).not.toEqual(global.simple_event.request);
    expect(request.url).toEqual("https://spectrust.com.spec-internal.com/");

    expect(global.simple_event.waitUntil.mock.calls.length).toEqual(0);
  });

  test("sending traffic to /spec_traffic in inline mode goes to spec proxy", () => {
    let event = global.simple_event;
    event.request.url = "https://spectrust.com/spec_traffic/somewhere";
    let request = specProxyProcessRequest(global.simple_event, {
      inlineMode: true,
    });
    expect(request.url).toEqual(
      "https://spectrust.com.spec-internal.com/spec_traffic/somewhere",
    );

    expect(global.simple_event.waitUntil.mock.calls.length).toEqual(0);
  });

  test("sending traffic to /spec_traffic in mirror mode goes to spec proxy", () => {
    let event = global.simple_event;
    event.request.url = "https://spectrust.com/spec_traffic/somewhere";
    let request = specProxyProcessRequest(global.simple_event, {
      inlineMode: false,
    });
    expect(request.url).toEqual(
      "https://spectrust.com.spec-internal.com/spec_traffic/somewhere",
    );

    expect(global.simple_event.waitUntil.mock.calls.length).toEqual(0);
  });

  test("sending traffic to /spec_traffic when it's disabled forwards traffic in mirror mode", () => {
    let event = global.simple_event;
    event.request.url = "https://spectrust.com/spec_traffic/somewhere";
    let request = specProxyProcessRequest(global.simple_event, {
      disableSpecTraffic: true,
    });
    expect(request).toEqual(global.simple_event.request);

    expect(global.simple_event.waitUntil.mock.calls.length).toEqual(1);
  });

  test("response sets SpecID cookie", () => {
    let response = new Response(null, {
      status: 200,
      headers: {
        "X-Forwarded-For": "127.0.0.1",
        Host: "spectrust.com",
      },
    });

    response = specProxyProcessResponse(
      global.simple_event.request,
      response,
      {},
    );

    let setCookie = parseCookies(response.headers.get("Set-Cookie"));

    expect(setCookie).toHaveProperty("x-spec-id");
    expect(setCookie["Max-Age"]).toEqual("320000000");

    // now assert that we don't assign a new cookie if one exists!
    response = new Response(null, {
      status: 200,
      headers: {
        "X-Forwarded-For": "127.0.0.1",
        Host: "spectrust.com",
      },
    });
    global.simple_event.request.headers.set("Cookie", "x-spec-id=abcd");

    response = specProxyProcessResponse(global.simple_event.request, response);
    let newSetCookie = response.headers.get("Set-Cookie");
    expect(newSetCookie).toBeNull();
  });

  test("response set SpecID doesn't erase other set-cookies", () => {
    let response = new Response(null, {
      status: 200,
      headers: {
        "X-Forwarded-For": "127.0.0.1",
        Host: "spectrust.com",
        "Set-Cookie": "a=cookie",
      },
    });

    response = specProxyProcessResponse(
      global.simple_event.request,
      response,
      {},
    );

    expect(response.headers.get("Set-Cookie").split(",")).toHaveLength(2);
  });

  test("response doesn't set SpecID in inline mode", () => {
    let response = new Response(null, {
      status: 200,
      headers: {
        "X-Forwarded-For": "127.0.0.1",
        Host: "spectrust.com",
      },
    });

    response = specProxyProcessResponse(global.simple_event.request, response, {
      inlineMode: true,
    });

    expect(response.headers.get("Set-Cookie")).toBeNull();
  });
});

//---------------------------------------------
// Private Function Testing
//---------------------------------------------

// "rewire" the library module to allow us to reach private functions
let spec = rewire("./dist/index.js");
// setup the jest global mock
spec.__set__({ self: global.self });

describe("shouldHandleRequest", () => {
  let shouldHandleRequest = spec.__get__("shouldHandleRequest");

  test.each([
    ["0.0.0.0", undefined, true],
    ["0.0.0.40", undefined, true],
    ["0.0.0.99", undefined, true],
    ["0.0.0.100", undefined, true], // "wraps" to 0
    ["0.0.0.0", 100, true],
    ["0.0.0.40", 100, true],
    ["0.0.0.99", 100, true],
    ["0.0.0.0", 100, true],
    ["0.0.0.40", 50, true],
    ["0.0.0.49", 50, true],
    ["0.0.0.50", 50, false],
    ["0.0.0.60", 50, false],
    ["0.0.0.0", 0, false],
    ["0.0.0.40", 0, false],
    ["0.0.0.99", 0, false],
    ["0.0.0.100", 0, false],
    // some legit-looking IPs that don't exploit our algorithm for testing
    ["24.68.195.11", 50, false],
    ["74.232.255.255", 50, true],
    ["89.2.79.2", 50, false],
    ["67.67.67.67", 50, false],
    ["10.0.0.8", 50, true],
  ])("traffic split [ip=%s, split=%i]", (ip, split, expected) => {
    let headers = new Headers({ "x-forwarded-for": ip });
    expect(
      shouldHandleRequest(TEST_URL, headers, {
        percentageOfIPs: split,
      }),
    ).toEqual(expected);
  });

  test.each([
    ["0.0.0.100", undefined, true], // "wraps" to 0
    ["0.0.0.99", 100, true],
    ["0.0.0.0", 100, true],
    ["0.0.0.49", 50, true],
    ["0.0.0.50", 50, true],
    ["0.0.0.60", 50, true],
    ["0.0.0.0", 0, true],
    ["0.0.0.40", 0, true],
    ["0.0.0.99", 0, true],
    ["0.0.0.100", 0, true],
    // some legit-looking IPs that don't exploit our algorithm for testing
    ["24.68.195.11", 50, true],
    ["74.232.255.255", 50, true],
    ["89.2.79.2", 50, true],
    ["67.67.67.67", 50, true],
    ["10.0.0.8", 50, true],
  ])(
    "traffic split [ip=%s, split=%i], always send to /spec_traffic when enabled",
    (ip, split, expected) => {
      let headers = new Headers({ "x-forwarded-for": ip });
      expect(
        shouldHandleRequest(TEST_SPEC_TRAFFIC_URL, headers, {
          percentageOfIPs: split,
        }),
      ).toEqual(expected);
    },
  );

  test.each([
    ["0.0.0.100", undefined, true], // "wraps" to 0
    ["0.0.0.99", 100, true],
    ["0.0.0.0", 100, true],
    ["0.0.0.49", 50, true],
    ["0.0.0.50", 50, false],
    ["0.0.0.60", 50, false],
    ["0.0.0.0", 0, false],
    ["0.0.0.40", 0, false],
    ["0.0.0.99", 0, false],
    ["0.0.0.100", 0, false],
    // some legit-looking IPs that don't exploit our algorithm for testing
    ["24.68.195.11", 50, false],
    ["74.232.255.255", 50, true],
    ["89.2.79.2", 50, false],
    ["67.67.67.67", 50, false],
    ["10.0.0.8", 50, true],
  ])(
    "traffic split [ip=%s, split=%i], normal behavior when /spec_traffic disabled",
    (ip, split, expected) => {
      let headers = new Headers({ "x-forwarded-for": ip });
      expect(
        shouldHandleRequest(TEST_SPEC_TRAFFIC_URL, headers, {
          percentageOfIPs: split,
          disableSpecTraffic: true,
        }),
      ).toEqual(expected);
    },
  );

  test.each([
    ["X-Forwarded-For", 50, "0.0.0.0", true],
    ["X-Forwarded-For", 50, "0.0.0.60", false],
    ["Not-Checked-Header", 50, "0.0.0.0", false],
    ["Not-Checked-Header", 50, "0.0.0.99", false],
    // if it's 100% split, we expect to handle the request even if we can't find the
    // IP address on this header
    ["X-Forwarded-For", 100, "0.0.0.60", true],
    ["Not-Checked-Header", 100, "0.0.0.99", true],
  ])(
    'ip from header "%s" with %i%% split for ip %s"',
    (header, split, ip, expected) => {
      let headers = new Headers({});
      headers.set(header, ip);
      expect(
        shouldHandleRequest(TEST_URL, headers, {
          percentageOfIPs: split,
        }),
      ).toEqual(expected);
    },
  );

  // Check NaN handling, just in case.
  test.each([
    ["X-Forwarded-For", 50, "wtf", false],
    ["X-Forwarded-For", 99, "srs", false],
    // if it's 100% split, we expect to handle the request even if we can't find the
    // IP address on this header
    ["X-Forwarded-For", 100, "susfam", true],
    ["X-Forwarded-For", 100, ":ghost-shrug:", true],
  ])(
    'ip from header "%s" with %i%% split for ip %s"',
    (header, split, ip, expected) => {
      let headers = new Headers({});
      headers.set(header, ip);
      expect(
        shouldHandleRequest(TEST_URL, headers, {
          percentageOfIPs: split,
        }),
      ).toEqual(expected);
    },
  );
});

describe("extractTopLevelDomain", () => {
  let extractTopLevelDomain = spec.__get__("extractTopLevelDomain");

  test.each([
    ["spec-trust.com", "spec-trust.com"],
    ["somewhere.spec-trust.com", "spec-trust.com"],
    ["somewhere.spec-trust.com.spec-internal.com", "spec-trust.com"],
    ["somewhere.spec-trust.edu", "spec-trust.edu"],
    ["somewhere.spec-trust.edu.spec-internal.com", "spec-trust.edu"],
    ["somewhere.spec-trust.gov", "spec-trust.gov"],
    ["somewhere.spec-trust.gov.spec-internal.com", "spec-trust.gov"],
    ["somewhere.spec-trust.int", "spec-trust.int"],
    ["somewhere.spec-trust.int.spec-internal.com", "spec-trust.int"],
    ["somewhere.spec-trust.net", "spec-trust.net"],
    ["somewhere.spec-trust.net.spec-internal.com", "spec-trust.net"],
    ["somewhere.spec-trust.org", "spec-trust.org"],
    ["somewhere.spec-trust.org.spec-internal.com", "spec-trust.org"],
    ["somewhere.spec-trust.uk", "spec-trust.uk"],
    ["somewhere.spec-trust.uk.spec-internal.com", "spec-trust.uk"],
    ["many.many.many.many.sub-domains.spec-trust.com", "spec-trust.com"],
    ["any.suffixes", "any.suffixes"],
    // if we don't match any known top-level domain suffix, we just get the whole thing
    [
      "subdomains.irrelevant.any.suffixes",
      "subdomains.irrelevant.any.suffixes",
    ],
    ["invalid-top-level-though", "invalid-top-level-though"],
    // invalid top-level domain suffix, but with spec-internal on the end winds up
    // matching to spec-internal
    ["somewhere.fun.to.go.spec-internal.com", "spec-internal.com"],
    ["staging.special.somebody.co.uk.spec-internal.com", "somebody.co.uk"],
    ["staging.somebody.ac.uk.spec-internal.com", "somebody.ac.uk"],
    ["staging.somebody.co.uk.spec-internal.com", "somebody.co.uk"],
    ["staging.somebody.gov.uk.spec-internal.com", "somebody.gov.uk"],
    ["staging.somebody.ltd.uk.spec-internal.com", "somebody.ltd.uk"],
    ["staging.somebody.me.uk.spec-internal.com", "somebody.me.uk"],
    ["staging.somebody.net.uk.spec-internal.com", "somebody.net.uk"],
    ["staging.somebody.nhs.uk.spec-internal.com", "somebody.nhs.uk"],
    ["staging.somebody.plc.uk.spec-internal.com", "somebody.plc.uk"],
    ["staging.somebody.police.uk.spec-internal.com", "somebody.police.uk"],
  ])("extract top level domain from %s", (host, expected) => {
    let headers = new Headers({ Host: host });
    expect(extractTopLevelDomain(headers)).toEqual(expected);
  });
});
