import { readFileSync } from "node:fs";
import { join } from "node:path";
import { apiToolPath, TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { TOOL_DISPLAY_MODES } from "../../../apps/web/src/lib/tool-display-modes.js";
import { fixtureDir } from "../../fixtures/index.js";
import {
  buildTestApp,
  createMultipartPayload,
  loginAsAdmin,
  type TestApp,
} from "../test-server.js";

/**
 * Hostile-input matrix: every tool route must reject malformed, truncated,
 * lying, or bomb-shaped files with a clean 4xx (or 501 for uninstalled AI
 * bundles). A 500, a hang, or a success response for garbage is a bug in the
 * tool, not in this test.
 *
 * Fixtures come from scripts/generate-hostile-fixtures.mjs (committed).
 */

/** Fixtures that are unreadable garbage: the server must NOT report success. */
const GARBAGE_FIXTURES = ["truncated.jpg", "zero-byte.png", "garbage.jpg", "bomb-50000x50000.png"];

/** Valid PNG bytes behind a lying .jpg extension: success or clean 4xx are both
 * fine (tools sniff content, some require a specific input type); 5xx is not. */
const MISMATCH_FIXTURE = "png-bytes.jpg";

const REJECT_STATUSES = new Set([400, 413, 415, 422, 501]);

// Tools that never decode the uploaded pixel data: no-dropzone generators take
// input from settings, bulk-rename zips bytes verbatim, and the metadata tools
// operate on metadata segments only. Succeeding on a file with a valid header
// but broken pixel data is correct behavior for them; everything else must
// reject.
const INPUT_AGNOSTIC = new Set(
  TOOLS.filter((t) => TOOL_DISPLAY_MODES[t.id] === "no-dropzone").map((t) => t.id),
);
INPUT_AGNOSTIC.add("bulk-rename");
INPUT_AGNOSTIC.add("edit-metadata");
INPUT_AGNOSTIC.add("strip-metadata");
INPUT_AGNOSTIC.add("info");
INPUT_AGNOSTIC.add("image-to-base64");

/** Server-error statuses; 501 (feature not installed) is a clean rejection. */
const SERVER_ERRORS = [500, 502, 503, 504];

describe("hostile input matrix", () => {
  let testApp: TestApp;
  let adminToken: string;

  beforeAll(async () => {
    testApp = await buildTestApp();
    adminToken = await loginAsAdmin(testApp.app);
  }, 30_000);

  afterAll(async () => {
    await testApp.cleanup();
  }, 10_000);

  async function postFile(toolId: string, fixtureName: string) {
    const content = readFileSync(join(fixtureDir.hostile, fixtureName));
    const { body, contentType } = createMultipartPayload([
      { name: "file", filename: fixtureName, contentType: "application/octet-stream", content },
      { name: "settings", content: "{}" },
    ]);
    const started = Date.now();
    const res = await testApp.app.inject({
      method: "POST",
      url: apiToolPath(toolId),
      headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
      body,
    });
    return { res, elapsedMs: Date.now() - started };
  }

  for (const tool of TOOLS) {
    const toolId = tool.id;
    it(`${toolId} rejects hostile files cleanly`, async () => {
      for (const fixture of GARBAGE_FIXTURES) {
        const { res, elapsedMs } = await postFile(toolId, fixture);

        expect(
          SERVER_ERRORS.includes(res.statusCode),
          `${toolId} returned ${res.statusCode} for ${fixture}: ${res.body.slice(0, 300)}`,
        ).toBe(false);
        expect(elapsedMs, `${toolId} took ${elapsedMs}ms on ${fixture}`).toBeLessThan(15_000);

        if (INPUT_AGNOSTIC.has(toolId)) continue;

        expect(
          REJECT_STATUSES.has(res.statusCode),
          `${toolId} did not reject ${fixture} (got ${res.statusCode})`,
        ).toBe(true);

        // Error responses must be structured JSON, not stack traces
        const parsed = JSON.parse(res.body) as { error?: string };
        expect(parsed.error, `${toolId} 4xx body has no error field for ${fixture}`).toBeTruthy();
      }

      // Lying extension with valid content: anything but a server error is fine
      const { res } = await postFile(toolId, MISMATCH_FIXTURE);
      expect(
        SERVER_ERRORS.includes(res.statusCode),
        `${toolId} returned ${res.statusCode} for ${MISMATCH_FIXTURE}: ${res.body.slice(0, 300)}`,
      ).toBe(false);
    }, 120_000);
  }
});
