import { beforeEach, describe, expect, it, vi } from "vitest";

const { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({
  state: {
    response: {
      statusCode: 200,
      headers: { "content-type": "application/json" },
      body: Buffer.from(""),
    },
    error: null,
    lastAgent: null,
    lastAgentOptions: null,
    lastRequestParams: null,
    lastWrittenBody: null,
  },
  cache: {
    get: vi.fn(),
    put: vi.fn(),
  },
  logger: {
    debug: vi.fn(),
    error: vi.fn(),
  },
  dns: {
    lookup: vi.fn(),
    resolve4: vi.fn(),
    resolve6: vi.fn(),
  },
  net: {
    isIP: vi.fn(),
  },
  cookieJar: {
    addCookieToJar: vi.fn(),
    setCookieHeader: vi.fn(),
  },
}));

vi.mock("node:dns", () => ({
  default: dns,
}));

vi.mock("node:net", () => ({
  default: net,
}));

vi.mock("follow-redirects", async () => {
  const { EventEmitter } = await import("node:events");
  const { Readable } = await import("node:stream");

  function Agent(opts) {
    this.opts = opts;
  }

  function makeRequest() {
    return (url, params, cb) => {
      const req = new EventEmitter();
      state.lastRequestParams = params;
      state.lastWrittenBody = null;
      req.write = vi.fn((chunk) => {
        state.lastWrittenBody = chunk;
      });
      req.end = vi.fn(() => {
        state.lastAgent = params?.agent ?? null;
        state.lastAgentOptions = params?.agent?.opts ?? null;
        if (state.error) {
          req.emit("error", state.error);
          return;
        }

        const res = new Readable({
          read() {
            this.push(state.response.body);
            this.push(null);
          },
        });
        res.statusCode = state.response.statusCode;
        res.headers = state.response.headers;
        cb(res);
      });
      return req;
    };
  }

  return {
    http: { request: makeRequest(), Agent },
    https: { request: makeRequest(), Agent },
  };
});

vi.mock("memory-cache", () => ({
  default: cache,
}));

vi.mock("./cookie-jar", () => cookieJar);

vi.mock("utils/logger", () => ({
  default: () => logger,
}));

describe("utils/proxy/http cachedRequest", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    state.error = null;
    state.response = {
      statusCode: 200,
      headers: { "content-type": "application/json" },
      body: Buffer.from(""),
    };
    state.lastAgent = null;
    state.lastAgentOptions = null;
    state.lastRequestParams = null;
    state.lastWrittenBody = null;
    vi.resetModules();
  });

  it("returns cached values without calling httpProxy", async () => {
    cache.get.mockReturnValueOnce({ ok: true });
    const httpMod = await import("./http");
    const spy = vi.spyOn(httpMod, "httpProxy");

    const data = await httpMod.cachedRequest("http://example.com");

    expect(data).toEqual({ ok: true });
    expect(spy).not.toHaveBeenCalled();
  });

  it("parses json buffer responses and caches the result", async () => {
    cache.get.mockReturnValueOnce(null);
    state.response.body = Buffer.from('{"a":1}');
    const httpMod = await import("./http");

    const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");

    expect(data).toEqual({ a: 1 });
    expect(cache.put).toHaveBeenCalledWith("http://example.com/data", { a: 1 }, 1 * 1000 * 60);
  });

  it("falls back to string when cachedRequest cannot parse json", async () => {
    cache.get.mockReturnValueOnce(null);
    state.response.body = Buffer.from("not-json");
    const httpMod = await import("./http");

    const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");

    expect(data).toBe("not-json");
    expect(logger.debug).toHaveBeenCalled();
  });
});

describe("utils/proxy/http homepageDNSLookupFn", () => {
  const getLookupFn = async () => {
    const httpMod = await import("./http");
    await httpMod.httpProxy("http://example.com");
    expect(state.lastAgentOptions?.lookup).toEqual(expect.any(Function));
    return state.lastAgentOptions.lookup;
  };

  beforeEach(() => {
    vi.clearAllMocks();
    state.error = null;
    state.lastAgentOptions = null;
    net.isIP.mockReturnValue(0);
    dns.lookup.mockImplementation((hostname, options, cb) => cb(null, "127.0.0.1", 4));
    dns.resolve4.mockImplementation((hostname, cb) => cb(null, ["127.0.0.1"]));
    dns.resolve6.mockImplementation((hostname, cb) => cb(null, ["::1"]));
    vi.resetModules();
  });

  it("short-circuits when hostname is already an IP (all=false)", async () => {
    const lookup = await getLookupFn();
    net.isIP.mockReturnValueOnce(4);
    const cb = vi.fn();

    lookup("1.2.3.4", cb);

    expect(dns.lookup).not.toHaveBeenCalled();
    expect(cb).toHaveBeenCalledWith(null, "1.2.3.4", 4);
  });

  it("short-circuits when hostname is already an IP (all=true)", async () => {
    const lookup = await getLookupFn();
    net.isIP.mockReturnValueOnce(6);
    const cb = vi.fn();

    lookup("::1", { all: true }, cb);

    expect(dns.lookup).not.toHaveBeenCalled();
    expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
  });

  it("uses dns.lookup when it succeeds (2-argument form)", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();

    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(null, "10.0.0.1", 4));
    lookup("example.com", cb);

    expect(dns.lookup).toHaveBeenCalledWith("example.com", {}, expect.any(Function));
    expect(dns.resolve4).not.toHaveBeenCalled();
    expect(dns.resolve6).not.toHaveBeenCalled();
    expect(cb).toHaveBeenCalledWith(null, "10.0.0.1", 4);
  });

  it("does not fall back for non-ENOTFOUND/EAI_NONAME lookup errors", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();
    const err = Object.assign(new Error("temporary"), { code: "EAI_AGAIN" });
    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(err));

    lookup("example.com", { all: true }, cb);

    expect(dns.resolve4).not.toHaveBeenCalled();
    expect(dns.resolve6).not.toHaveBeenCalled();
    expect(cb).toHaveBeenCalledWith(err);
  });

  it("falls back to resolve4 when lookup fails with ENOTFOUND and family=4", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();
    const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });

    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
    dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["1.1.1.1"]));

    lookup("example.com", { family: 4, all: true }, cb);

    expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
    expect(dns.resolve6).not.toHaveBeenCalled();
    expect(cb).toHaveBeenCalledWith(null, [{ address: "1.1.1.1", family: 4 }]);
    expect(logger.debug).toHaveBeenCalledWith("DNS fallback to c-ares resolver succeeded for %s", "example.com");
  });

  it("falls back to resolve6 when lookup fails with ENOTFOUND and family=6", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();
    const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });

    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
    dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));

    lookup("example.com", 6, cb);

    expect(dns.lookup).toHaveBeenCalledWith("example.com", { family: 6 }, expect.any(Function));
    expect(dns.resolve4).not.toHaveBeenCalled();
    expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
    expect(cb).toHaveBeenCalledWith(null, ["::1"], 6);
  });

  it("tries resolve4 then resolve6 when lookup fails and no family is specified", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();
    const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });

    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
    dns.resolve4.mockImplementationOnce((hostname, resolveCb) =>
      resolveCb(Object.assign(new Error("v4 failed"), { code: "EAI_FAIL" })),
    );
    dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));

    lookup("example.com", { all: true }, cb);

    expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
    expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
    expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
  });

  it("returns ENOTFOUND when fallback resolver returns no addresses", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();
    const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });

    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
    dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, []));

    lookup("example.com", { family: 4, all: true }, cb);

    const err = cb.mock.calls[0][0];
    expect(err).toBeInstanceOf(Error);
    expect(err.code).toBe("ENOTFOUND");
    expect(dns.resolve6).not.toHaveBeenCalled();
  });

  it("returns resolve error when fallback resolver fails", async () => {
    const lookup = await getLookupFn();
    const cb = vi.fn();
    const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
    const resolveErr = Object.assign(new Error("resolver down"), { code: "EAI_FAIL" });

    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
    dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(resolveErr));

    lookup("example.com", { family: 4, all: true }, cb);

    expect(cb).toHaveBeenCalledWith(resolveErr);
    expect(logger.debug).toHaveBeenCalledWith(
      "DNS fallback failed for %s: lookup error=%s, resolve error=%s",
      "example.com",
      "ENOTFOUND",
      "EAI_FAIL",
    );
  });
});

describe("utils/proxy/http httpProxy", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    state.error = null;
    state.response = {
      statusCode: 200,
      headers: { "content-type": "application/json" },
      body: Buffer.from("ok"),
    };
    state.lastAgent = null;
    state.lastAgentOptions = null;
    state.lastRequestParams = null;
    state.lastWrittenBody = null;
    process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "";
    vi.resetModules();
  });

  it("sets content-length and writes request bodies", async () => {
    const httpMod = await import("./http");
    const body = "abc";

    const [status] = await httpMod.httpProxy("http://example.com", { method: "POST", body, headers: {} });

    expect(status).toBe(200);
    expect(state.lastRequestParams.headers["content-length"]).toBe(3);
    expect(state.lastWrittenBody).toBe(body);
  });

  it("installs a beforeRedirect hook and updates the cookie jar", async () => {
    const httpMod = await import("./http");
    await httpMod.httpProxy("http://example.com");

    expect(state.lastRequestParams.beforeRedirect).toEqual(expect.any(Function));
    expect(cookieJar.setCookieHeader).toHaveBeenCalled();
    expect(cookieJar.addCookieToJar).toHaveBeenCalled();
  });

  it("updates cookies during redirects via beforeRedirect", async () => {
    const httpMod = await import("./http");
    await httpMod.httpProxy("http://example.com");

    state.lastRequestParams.beforeRedirect(
      { href: "http://example.com/redirect" },
      { headers: { "set-cookie": ["a=b"] } },
    );

    expect(cookieJar.addCookieToJar).toHaveBeenCalledWith("http://example.com/redirect", { "set-cookie": ["a=b"] });
    expect(cookieJar.setCookieHeader).toHaveBeenCalledWith("http://example.com/redirect", expect.any(Object), {
      overwrite: true,
    });
  });

  it("supports gzip-compressed responses", async () => {
    const { gzipSync } = await import("node:zlib");
    state.response.headers["content-encoding"] = "gzip";
    state.response.body = gzipSync(Buffer.from("hello"));

    const httpMod = await import("./http");
    const [, , data] = await httpMod.httpProxy("http://example.com");

    expect(Buffer.from(data).toString()).toBe("hello");
  });

  it("logs when gzip decoding emits an error", async () => {
    const { PassThrough } = await import("node:stream");

    vi.doMock("node:zlib", async () => {
      const actual = await vi.importActual("node:zlib");
      return {
        ...actual,
        createUnzip: () => {
          const pt = new PassThrough();
          pt.on("pipe", () => {
            queueMicrotask(() => {
              pt.emit("error", new Error("bad gzip"));
              pt.end();
            });
          });
          return pt;
        },
      };
    });

    vi.resetModules();
    const httpMod = await import("./http");

    state.response.headers["content-encoding"] = "gzip";
    state.response.body = Buffer.from("hello");

    await httpMod.httpProxy("http://example.com");

    expect(logger.error).toHaveBeenCalled();

    vi.unmock("node:zlib");
  });

  it("applies strict IPv4 agent options when HOMEPAGE_PROXY_DISABLE_IPV6 is true", async () => {
    process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "true";
    const httpMod = await import("./http");

    await httpMod.httpProxy("http://example.com");

    expect(state.lastAgentOptions.keepAlive).toBe(true);
    expect(state.lastAgentOptions.family).toBe(4);
    expect(state.lastAgentOptions.autoSelectFamily).toBe(false);
  });

  it("uses the https agent with rejectUnauthorized=false for https:// URLs", async () => {
    const httpMod = await import("./http");

    await httpMod.httpProxy("https://example.com");

    expect(state.lastAgentOptions.rejectUnauthorized).toBe(false);
  });

  it("reuses the same keep-alive agent for repeated http requests", async () => {
    const httpMod = await import("./http");

    await httpMod.httpProxy("http://example.com/first");
    const firstAgent = state.lastAgent;
    await httpMod.httpProxy("http://example.com/second");

    expect(state.lastAgentOptions.keepAlive).toBe(true);
    expect(state.lastAgent).toBe(firstAgent);
  });

  it("returns a sanitized error response when the request fails", async () => {
    state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" });
    const httpMod = await import("./http");

    const [status, contentType, data] = await httpMod.httpProxy("http://example.com/?apikey=secret");

    expect(status).toBe(500);
    expect(contentType).toBe("application/json");
    expect(data.error.message).toBe("boom");
    expect(data.error.url).toBe("example.com (see logs for details)");
  });
});
