import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, test, vi } from "vitest";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import type { HooksConfigResolved } from "./hooks.js";
import { canonicalizePathVariant } from "./security-path.js";
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
import { withTempConfig } from "./test-temp-config.js";

function createRequest(params: {
  path: string;
  authorization?: string;
  method?: string;
}): IncomingMessage {
  const headers: Record<string, string> = {
    host: "localhost:18789",
  };
  if (params.authorization) {
    headers.authorization = params.authorization;
  }
  return {
    method: params.method ?? "GET",
    url: params.path,
    headers,
    socket: { remoteAddress: "127.0.0.1" },
  } as IncomingMessage;
}

function createResponse(): {
  res: ServerResponse;
  setHeader: ReturnType<typeof vi.fn>;
  end: ReturnType<typeof vi.fn>;
  getBody: () => string;
} {
  const setHeader = vi.fn();
  let body = "";
  const end = vi.fn((chunk?: unknown) => {
    if (typeof chunk === "string") {
      body = chunk;
      return;
    }
    if (chunk == null) {
      body = "";
      return;
    }
    body = JSON.stringify(chunk);
  });
  const res = {
    headersSent: false,
    statusCode: 200,
    setHeader,
    end,
  } as unknown as ServerResponse;
  return {
    res,
    setHeader,
    end,
    getBody: () => body,
  };
}

async function dispatchRequest(
  server: ReturnType<typeof createGatewayHttpServer>,
  req: IncomingMessage,
  res: ServerResponse,
): Promise<void> {
  server.emit("request", req, res);
  await new Promise((resolve) => setImmediate(resolve));
}

function createHooksConfig(): HooksConfigResolved {
  return {
    basePath: "/hooks",
    token: "hook-secret",
    maxBodyBytes: 1024,
    mappings: [],
    agentPolicy: {
      defaultAgentId: "main",
      knownAgentIds: new Set(["main"]),
      allowedAgentIds: undefined,
    },
    sessionPolicy: {
      allowRequestSessionKey: false,
      defaultSessionKey: undefined,
      allowedSessionKeyPrefixes: undefined,
    },
  };
}

function canonicalizePluginPath(pathname: string): string {
  return canonicalizePathVariant(pathname);
}

type RouteVariant = {
  label: string;
  path: string;
};

const CANONICAL_UNAUTH_VARIANTS: RouteVariant[] = [
  { label: "case-variant", path: "/API/channels/nostr/default/profile" },
  { label: "encoded-slash", path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" },
  { label: "encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
  { label: "dot-traversal-encoded-slash", path: "/api/foo/..%2fchannels/nostr/default/profile" },
  {
    label: "dot-traversal-encoded-dotdot-slash",
    path: "/api/foo/%2e%2e%2fchannels/nostr/default/profile",
  },
  {
    label: "dot-traversal-double-encoded",
    path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
  },
  { label: "duplicate-slashes", path: "/api/channels//nostr/default/profile" },
  { label: "trailing-slash", path: "/api/channels/nostr/default/profile/" },
  { label: "malformed-short-percent", path: "/api/channels%2" },
  { label: "malformed-double-slash-short-percent", path: "/api//channels%2" },
];

const CANONICAL_AUTH_VARIANTS: RouteVariant[] = [
  { label: "auth-case-variant", path: "/API/channels/nostr/default/profile" },
  { label: "auth-encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
  { label: "auth-duplicate-trailing-slash", path: "/api/channels//nostr/default/profile/" },
  {
    label: "auth-dot-traversal-encoded-slash",
    path: "/api/foo/..%2fchannels/nostr/default/profile",
  },
  {
    label: "auth-dot-traversal-double-encoded",
    path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
  },
];

function buildChannelPathFuzzCorpus(): RouteVariant[] {
  const variants = [
    "/api/channels/nostr/default/profile",
    "/API/channels/nostr/default/profile",
    "/api/foo/..%2fchannels/nostr/default/profile",
    "/api/foo/%2e%2e%2fchannels/nostr/default/profile",
    "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
    "/api/channels//nostr/default/profile/",
    "/api/channels%2Fnostr%2Fdefault%2Fprofile",
    "/api/channels%252Fnostr%252Fdefault%252Fprofile",
    "/api//channels/nostr/default/profile",
    "/api/channels%2",
    "/api/channels%zz",
    "/api//channels%2",
    "/api//channels%zz",
  ];
  return variants.map((path) => ({ label: `fuzz:${path}`, path }));
}

async function expectUnauthorizedVariants(params: {
  server: ReturnType<typeof createGatewayHttpServer>;
  variants: RouteVariant[];
}) {
  for (const variant of params.variants) {
    const response = createResponse();
    await dispatchRequest(params.server, createRequest({ path: variant.path }), response.res);
    expect(response.res.statusCode, variant.label).toBe(401);
    expect(response.getBody(), variant.label).toContain("Unauthorized");
  }
}

async function expectAuthorizedVariants(params: {
  server: ReturnType<typeof createGatewayHttpServer>;
  variants: RouteVariant[];
  authorization: string;
}) {
  for (const variant of params.variants) {
    const response = createResponse();
    await dispatchRequest(
      params.server,
      createRequest({
        path: variant.path,
        authorization: params.authorization,
      }),
      response.res,
    );
    expect(response.res.statusCode, variant.label).toBe(200);
    expect(response.getBody(), variant.label).toContain('"route":"channel-canonicalized"');
  }
}

describe("gateway plugin HTTP auth boundary", () => {
  test("applies default security headers and optional strict transport security", async () => {
    const resolvedAuth: ResolvedGatewayAuth = {
      mode: "none",
      token: undefined,
      password: undefined,
      allowTailscale: false,
    };

    await withTempConfig({
      cfg: { gateway: { trustedProxies: [] } },
      prefix: "openclaw-plugin-http-security-headers-test-",
      run: async () => {
        const withoutHsts = createGatewayHttpServer({
          canvasHost: null,
          clients: new Set(),
          controlUiEnabled: false,
          controlUiBasePath: "/__control__",
          openAiChatCompletionsEnabled: false,
          openResponsesEnabled: false,
          handleHooksRequest: async () => false,
          resolvedAuth,
        });
        const withoutHstsResponse = createResponse();
        await dispatchRequest(
          withoutHsts,
          createRequest({ path: "/missing" }),
          withoutHstsResponse.res,
        );
        expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith(
          "X-Content-Type-Options",
          "nosniff",
        );
        expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith(
          "Referrer-Policy",
          "no-referrer",
        );
        expect(withoutHstsResponse.setHeader).not.toHaveBeenCalledWith(
          "Strict-Transport-Security",
          expect.any(String),
        );

        const withHsts = createGatewayHttpServer({
          canvasHost: null,
          clients: new Set(),
          controlUiEnabled: false,
          controlUiBasePath: "/__control__",
          openAiChatCompletionsEnabled: false,
          openResponsesEnabled: false,
          strictTransportSecurityHeader: "max-age=31536000; includeSubDomains",
          handleHooksRequest: async () => false,
          resolvedAuth,
        });
        const withHstsResponse = createResponse();
        await dispatchRequest(withHsts, createRequest({ path: "/missing" }), withHstsResponse.res);
        expect(withHstsResponse.setHeader).toHaveBeenCalledWith(
          "Strict-Transport-Security",
          "max-age=31536000; includeSubDomains",
        );
      },
    });
  });

  test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => {
    const resolvedAuth: ResolvedGatewayAuth = {
      mode: "token",
      token: "test-token",
      password: undefined,
      allowTailscale: false,
    };

    await withTempConfig({
      cfg: { gateway: { trustedProxies: [] } },
      prefix: "openclaw-plugin-http-auth-test-",
      run: async () => {
        const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
          const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
          if (pathname === "/api/channels") {
            res.statusCode = 200;
            res.setHeader("Content-Type", "application/json; charset=utf-8");
            res.end(JSON.stringify({ ok: true, route: "channel-root" }));
            return true;
          }
          if (pathname === "/api/channels/nostr/default/profile") {
            res.statusCode = 200;
            res.setHeader("Content-Type", "application/json; charset=utf-8");
            res.end(JSON.stringify({ ok: true, route: "channel" }));
            return true;
          }
          if (pathname === "/plugin/public") {
            res.statusCode = 200;
            res.setHeader("Content-Type", "application/json; charset=utf-8");
            res.end(JSON.stringify({ ok: true, route: "public" }));
            return true;
          }
          return false;
        });

        const server = createGatewayHttpServer({
          canvasHost: null,
          clients: new Set(),
          controlUiEnabled: false,
          controlUiBasePath: "/__control__",
          openAiChatCompletionsEnabled: false,
          openResponsesEnabled: false,
          handleHooksRequest: async () => false,
          handlePluginRequest,
          resolvedAuth,
        });

        const unauthenticated = createResponse();
        await dispatchRequest(
          server,
          createRequest({ path: "/api/channels/nostr/default/profile" }),
          unauthenticated.res,
        );
        expect(unauthenticated.res.statusCode).toBe(401);
        expect(unauthenticated.getBody()).toContain("Unauthorized");
        expect(handlePluginRequest).not.toHaveBeenCalled();

        const unauthenticatedRoot = createResponse();
        await dispatchRequest(
          server,
          createRequest({ path: "/api/channels" }),
          unauthenticatedRoot.res,
        );
        expect(unauthenticatedRoot.res.statusCode).toBe(401);
        expect(unauthenticatedRoot.getBody()).toContain("Unauthorized");
        expect(handlePluginRequest).not.toHaveBeenCalled();

        const authenticated = createResponse();
        await dispatchRequest(
          server,
          createRequest({
            path: "/api/channels/nostr/default/profile",
            authorization: "Bearer test-token",
          }),
          authenticated.res,
        );
        expect(authenticated.res.statusCode).toBe(200);
        expect(authenticated.getBody()).toContain('"route":"channel"');

        const unauthenticatedPublic = createResponse();
        await dispatchRequest(
          server,
          createRequest({ path: "/plugin/public" }),
          unauthenticatedPublic.res,
        );
        expect(unauthenticatedPublic.res.statusCode).toBe(200);
        expect(unauthenticatedPublic.getBody()).toContain('"route":"public"');

        expect(handlePluginRequest).toHaveBeenCalledTimes(2);
      },
    });
  });

  test("requires gateway auth for canonicalized /api/channels variants", async () => {
    const resolvedAuth: ResolvedGatewayAuth = {
      mode: "token",
      token: "test-token",
      password: undefined,
      allowTailscale: false,
    };

    await withTempConfig({
      cfg: { gateway: { trustedProxies: [] } },
      prefix: "openclaw-plugin-http-auth-canonicalized-test-",
      run: async () => {
        const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
          const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
          const canonicalPath = canonicalizePluginPath(pathname);
          if (canonicalPath === "/api/channels/nostr/default/profile") {
            res.statusCode = 200;
            res.setHeader("Content-Type", "application/json; charset=utf-8");
            res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
            return true;
          }
          return false;
        });

        const server = createGatewayHttpServer({
          canvasHost: null,
          clients: new Set(),
          controlUiEnabled: false,
          controlUiBasePath: "/__control__",
          openAiChatCompletionsEnabled: false,
          openResponsesEnabled: false,
          handleHooksRequest: async () => false,
          handlePluginRequest,
          resolvedAuth,
        });

        await expectUnauthorizedVariants({ server, variants: CANONICAL_UNAUTH_VARIANTS });
        expect(handlePluginRequest).not.toHaveBeenCalled();

        await expectAuthorizedVariants({
          server,
          variants: CANONICAL_AUTH_VARIANTS,
          authorization: "Bearer test-token",
        });
        expect(handlePluginRequest).toHaveBeenCalledTimes(CANONICAL_AUTH_VARIANTS.length);
      },
    });
  });

  test("rejects unauthenticated plugin-channel fuzz corpus variants", async () => {
    const resolvedAuth: ResolvedGatewayAuth = {
      mode: "token",
      token: "test-token",
      password: undefined,
      allowTailscale: false,
    };

    await withTempConfig({
      cfg: { gateway: { trustedProxies: [] } },
      prefix: "openclaw-plugin-http-auth-fuzz-corpus-test-",
      run: async () => {
        const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
          const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
          const canonicalPath = canonicalizePluginPath(pathname);
          if (canonicalPath === "/api/channels/nostr/default/profile") {
            res.statusCode = 200;
            res.setHeader("Content-Type", "application/json; charset=utf-8");
            res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
            return true;
          }
          return false;
        });

        const server = createGatewayHttpServer({
          canvasHost: null,
          clients: new Set(),
          controlUiEnabled: false,
          controlUiBasePath: "/__control__",
          openAiChatCompletionsEnabled: false,
          openResponsesEnabled: false,
          handleHooksRequest: async () => false,
          handlePluginRequest,
          resolvedAuth,
        });

        for (const variant of buildChannelPathFuzzCorpus()) {
          const response = createResponse();
          await dispatchRequest(server, createRequest({ path: variant.path }), response.res);
          expect(response.res.statusCode, variant.label).not.toBe(200);
          expect(response.getBody(), variant.label).not.toContain(
            '"route":"channel-canonicalized"',
          );
        }
      },
    });
  });

  test.each(["0.0.0.0", "::"])(
    "returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s",
    async (bindHost) => {
      const resolvedAuth: ResolvedGatewayAuth = {
        mode: "none",
        token: undefined,
        password: undefined,
        allowTailscale: false,
      };

      await withTempConfig({
        cfg: { gateway: { trustedProxies: [] } },
        prefix: "openclaw-plugin-http-hooks-bindhost-",
        run: async () => {
          const handleHooksRequest = createHooksRequestHandler({
            getHooksConfig: () => createHooksConfig(),
            bindHost,
            port: 18789,
            logHooks: {
              warn: vi.fn(),
              debug: vi.fn(),
              info: vi.fn(),
              error: vi.fn(),
            } as unknown as ReturnType<typeof createSubsystemLogger>,
            dispatchWakeHook: () => {},
            dispatchAgentHook: () => "run-1",
          });
          const server = createGatewayHttpServer({
            canvasHost: null,
            clients: new Set(),
            controlUiEnabled: false,
            controlUiBasePath: "/__control__",
            openAiChatCompletionsEnabled: false,
            openResponsesEnabled: false,
            handleHooksRequest,
            resolvedAuth,
          });

          const response = createResponse();
          await dispatchRequest(server, createRequest({ path: "/" }), response.res);

          expect(response.res.statusCode).toBe(404);
          expect(response.getBody()).toBe("Not Found");
        },
      });
    },
  );

  test("rejects query-token hooks requests with bindHost=::", async () => {
    const resolvedAuth: ResolvedGatewayAuth = {
      mode: "none",
      token: undefined,
      password: undefined,
      allowTailscale: false,
    };

    await withTempConfig({
      cfg: { gateway: { trustedProxies: [] } },
      prefix: "openclaw-plugin-http-hooks-query-token-",
      run: async () => {
        const handleHooksRequest = createHooksRequestHandler({
          getHooksConfig: () => createHooksConfig(),
          bindHost: "::",
          port: 18789,
          logHooks: {
            warn: vi.fn(),
            debug: vi.fn(),
            info: vi.fn(),
            error: vi.fn(),
          } as unknown as ReturnType<typeof createSubsystemLogger>,
          dispatchWakeHook: () => {},
          dispatchAgentHook: () => "run-1",
        });
        const server = createGatewayHttpServer({
          canvasHost: null,
          clients: new Set(),
          controlUiEnabled: false,
          controlUiBasePath: "/__control__",
          openAiChatCompletionsEnabled: false,
          openResponsesEnabled: false,
          handleHooksRequest,
          resolvedAuth,
        });

        const response = createResponse();
        await dispatchRequest(
          server,
          createRequest({ path: "/hooks/wake?token=bad" }),
          response.res,
        );

        expect(response.res.statusCode).toBe(400);
        expect(response.getBody()).toContain("Hook token must be provided");
      },
    });
  });
});
