/**
 * Cross-format matrix integration test for AI tools.
 *
 * Tests 13 AI tools x 17 input formats to verify:
 *   - No tool returns 500 (server crash) for any format
 *   - Validation layer works: missing file -> 400, bad settings -> 400
 *   - Auth enforcement: unauthenticated -> 401
 *   - Format acceptance/rejection is graceful (200, 202, 400, 422, or 501)
 *
 * The Python AI sidecar is NOT running during integration tests.
 * AI tools will return 501 (FEATURE_NOT_INSTALLED) or 202 (accepted for
 * async processing). This is expected -- we are testing the validation
 * layer, NOT that AI processing succeeds.
 *
 * Some tools (smart-crop, content-aware-resize) use the factory pattern
 * or process synchronously and may return 200 or 422 depending on whether
 * the Go/Python binary is available.
 */

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtureDir, fixtures } from "../../fixtures/index.js";
import {
  buildTestApp,
  createMultipartPayload,
  loginAsAdmin,
  type TestApp,
} from "../test-server.js";

// ---------------------------------------------------------------------------
// Format sample definitions (matches format-matrix.test.ts)
// ---------------------------------------------------------------------------
interface FormatSample {
  name: string;
  file: string;
  mime: string;
  needsCliDecoder: boolean;
  needsHeifDecoder: boolean;
  mayFailValidation: boolean;
}

const FORMAT_SAMPLES: FormatSample[] = [
  {
    name: "JPEG",
    file: "sample.jpg",
    mime: "image/jpeg",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "PNG",
    file: "sample.png",
    mime: "image/png",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "WebP",
    file: "sample.webp",
    mime: "image/webp",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "GIF",
    file: "sample.gif",
    mime: "image/gif",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "AVIF",
    file: "sample.avif",
    mime: "image/avif",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "TIFF",
    file: "sample.tiff",
    mime: "image/tiff",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "BMP",
    file: "sample.bmp",
    mime: "image/bmp",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: true,
  },
  {
    name: "HEIC",
    file: "sample.heic",
    mime: "image/heic",
    needsCliDecoder: false,
    needsHeifDecoder: true,
    mayFailValidation: false,
  },
  {
    name: "HEIF",
    file: "sample.heif",
    mime: "image/heif",
    needsCliDecoder: false,
    needsHeifDecoder: true,
    mayFailValidation: false,
  },
  {
    name: "SVG",
    file: "sample.svg",
    mime: "image/svg+xml",
    needsCliDecoder: false,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "ICO",
    file: "sample.ico",
    mime: "image/x-icon",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "PSD",
    file: "sample.psd",
    mime: "image/vnd.adobe.photoshop",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "EXR",
    file: "sample.exr",
    mime: "image/x-exr",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "HDR",
    file: "sample.hdr",
    mime: "image/vnd.radiance",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "TGA",
    file: "sample.tga",
    mime: "image/x-tga",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "DNG",
    file: "sample.dng",
    mime: "image/x-adobe-dng",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: false,
  },
  {
    name: "JXL",
    file: "sample.jxl",
    mime: "image/jxl",
    needsCliDecoder: true,
    needsHeifDecoder: false,
    mayFailValidation: true,
  },
];

// ---------------------------------------------------------------------------
// AI tool definitions
// ---------------------------------------------------------------------------
interface AiToolDef {
  /** Tool route name (maps to /api/v1/tools/<id>) */
  id: string;
  /** Display name for test output */
  label: string;
  /** Settings JSON sent as the "settings" multipart field */
  settings: Record<string, unknown>;
  /**
   * Whether this tool uses the 501 FEATURE_NOT_INSTALLED guard.
   * Tools using createToolRoute factory (smart-crop) do not have it.
   */
  has501Guard: boolean;
  /**
   * Whether this tool requires a mask file (e.g. erase-object).
   */
  requiresMask: boolean;
  /**
   * A settings payload that should trigger Zod validation failure.
   * Used for the "invalid settings -> 400" tests.
   */
  invalidSettings: Record<string, unknown>;
}

const AI_TOOLS: AiToolDef[] = [
  {
    id: "remove-background",
    label: "Remove Background",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { blurIntensity: 999 },
  },
  {
    id: "upscale",
    label: "Upscale",
    settings: { scale: 2 },
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { scale: "not-a-number", format: 12345 },
  },
  {
    id: "ocr",
    label: "OCR",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { quality: "invalid-enum", language: "xx" },
  },
  {
    id: "blur-faces",
    label: "Blur Faces",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { blurRadius: 999 },
  },
  {
    id: "enhance-faces",
    label: "Enhance Faces",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { model: "nonexistent-model" },
  },
  {
    id: "smart-crop",
    label: "Smart Crop",
    settings: { mode: "subject", width: 100, height: 100 },
    has501Guard: false,
    requiresMask: false,
    invalidSettings: { mode: "invalid-mode-xyz" },
  },
  {
    id: "colorize",
    label: "Colorize",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { model: "nonexistent-model", intensity: 999 },
  },
  {
    id: "noise-removal",
    label: "Noise Removal",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { tier: "nonexistent-tier" },
  },
  {
    id: "red-eye-removal",
    label: "Red Eye Removal",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { sensitivity: -5 },
  },
  {
    id: "restore-photo",
    label: "Restore Photo",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { mode: "nonexistent-mode" },
  },
  {
    id: "passport-photo",
    label: "Passport Photo (Analyze)",
    settings: {},
    has501Guard: true,
    requiresMask: false,
    invalidSettings: {},
  },
  {
    id: "erase-object",
    label: "Erase Object",
    settings: {},
    has501Guard: true,
    requiresMask: true,
    invalidSettings: {},
  },
  {
    id: "content-aware-resize",
    label: "Content-Aware Resize",
    settings: { width: 50, square: false },
    has501Guard: false,
    requiresMask: false,
    invalidSettings: { blurRadius: 999 },
  },
  {
    id: "ai-canvas-expand",
    label: "AI Canvas Expand",
    settings: { extendTop: 50, extendRight: 0, extendBottom: 50, extendLeft: 0 },
    has501Guard: true,
    requiresMask: false,
    invalidSettings: { extendTop: -1 },
  },
];

// ---------------------------------------------------------------------------
// Valid status codes for AI tool responses during testing
// (no AI sidecar running, so processing won't complete)
// ---------------------------------------------------------------------------
/**
 * Acceptable status codes from AI tools in test:
 * - 200: synchronous success (e.g. smart-crop with subject mode)
 * - 202: accepted for async processing (tools that reply early)
 * - 400: format/validation rejected
 * - 422: processing error (format decode failure, etc.)
 * - 501: AI feature not installed (FEATURE_NOT_INSTALLED)
 */
const ACCEPTABLE_AI_CODES = [200, 202, 400, 422, 501];

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Get the route URL for a given AI tool.
 * passport-photo uses /analyze endpoint.
 */
function getToolUrl(toolId: string): string {
  if (toolId === "passport-photo") {
    return `${apiToolPath("passport-photo")}/analyze`;
  }
  return apiToolPath(toolId);
}

/**
 * Build multipart payload for an AI tool request.
 * Handles the mask requirement for erase-object.
 */
function buildAiPayload(
  fmt: FormatSample,
  tool: AiToolDef,
  buffer: Buffer,
  maskBuffer?: Buffer,
): { body: Buffer; contentType: string } {
  const fields: Array<{
    name: string;
    filename?: string;
    contentType?: string;
    content: Buffer | string;
  }> = [
    {
      name: "file",
      filename: fmt.file,
      contentType: fmt.mime,
      content: buffer,
    },
  ];

  // erase-object needs a mask file
  if (tool.requiresMask && maskBuffer) {
    fields.push({
      name: "mask",
      filename: "mask.png",
      contentType: "image/png",
      content: maskBuffer,
    });
  }

  // Add settings if non-empty
  if (Object.keys(tool.settings).length > 0) {
    fields.push({
      name: "settings",
      content: JSON.stringify(tool.settings),
    });
  }

  return createMultipartPayload(fields);
}

// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
/** Tiny PNG buffer used as mask for erase-object tests */
let maskBuffer: Buffer;

beforeAll(async () => {
  testApp = await buildTestApp();
  app = testApp.app;
  adminToken = await loginAsAdmin(app);

  // Use sample.png as the mask for erase-object
  const maskPath = fixtures.image.formats("png");
  maskBuffer = readFileSync(maskPath);
}, 30_000);

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

// ---------------------------------------------------------------------------
// Cross-format matrix: every AI tool x every format
// ---------------------------------------------------------------------------
describe("AI tool cross-format matrix", () => {
  for (const fmt of FORMAT_SAMPLES) {
    describe(`${fmt.name} input (${fmt.file})`, () => {
      const fixturePath = join(fixtureDir.formats, fmt.file);

      for (const tool of AI_TOOLS) {
        const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;

        it(
          `${tool.label}`,
          async () => {
            if (!existsSync(fixturePath)) return;

            const buffer = readFileSync(fixturePath);
            const { body: payload, contentType } = buildAiPayload(
              fmt,
              tool,
              buffer,
              tool.requiresMask ? maskBuffer : undefined,
            );

            const res = await app.inject({
              method: "POST",
              url: getToolUrl(tool.id),
              headers: {
                authorization: `Bearer ${adminToken}`,
                "content-type": contentType,
              },
              body: payload,
            });

            // Must NEVER return 500 (server crash)
            expect(res.statusCode, `${tool.label} + ${fmt.name}: got 500 server error`).not.toBe(
              500,
            );

            // Must be one of the acceptable codes
            expect(ACCEPTABLE_AI_CODES).toContain(res.statusCode);

            // Response must always be valid JSON
            const body = JSON.parse(res.body);

            if (res.statusCode === 501) {
              // Verify FEATURE_NOT_INSTALLED shape
              expect(body.code).toBe("FEATURE_NOT_INSTALLED");
              expect(body.error).toBeDefined();
              expect(typeof body.error).toBe("string");
            } else if (res.statusCode === 202) {
              // Async processing accepted
              expect(body.jobId).toBeDefined();
              expect(typeof body.jobId).toBe("string");
              expect(body.async).toBe(true);
            } else if (res.statusCode === 200) {
              // Synchronous success -- at minimum, response is valid JSON
              expect(typeof body).toBe("object");
            } else {
              // 400 or 422 error -- verify clean error shape
              expect(body.error).toBeDefined();
              expect(typeof body.error).toBe("string");
              expect(body.error.length).toBeGreaterThan(0);
            }
          },
          perTestTimeout,
        );
      }
    });
  }
});

// ---------------------------------------------------------------------------
// Missing file -> 400 for every AI tool
// ---------------------------------------------------------------------------
describe("Missing file returns 400", () => {
  for (const tool of AI_TOOLS) {
    it(`${tool.label}: no file -> 400`, async () => {
      // Send empty multipart with only settings
      const fields: Array<{
        name: string;
        filename?: string;
        contentType?: string;
        content: Buffer | string;
      }> = [];

      if (Object.keys(tool.settings).length > 0) {
        fields.push({
          name: "settings",
          content: JSON.stringify(tool.settings),
        });
      }

      // Add at least one field so the multipart is valid
      if (fields.length === 0) {
        fields.push({
          name: "settings",
          content: JSON.stringify({}),
        });
      }

      const { body: payload, contentType } = createMultipartPayload(fields);

      const res = await app.inject({
        method: "POST",
        url: getToolUrl(tool.id),
        headers: {
          authorization: `Bearer ${adminToken}`,
          "content-type": contentType,
        },
        body: payload,
      });

      // Tools with 501 guard may return 501 before checking for file
      if (tool.has501Guard) {
        expect([400, 501]).toContain(res.statusCode);
      } else {
        expect(res.statusCode).toBe(400);
      }

      const body = JSON.parse(res.body);
      expect(body.error).toBeDefined();
      expect(typeof body.error).toBe("string");
    });
  }
});

// ---------------------------------------------------------------------------
// Unauthenticated -> 401 for every AI tool
// ---------------------------------------------------------------------------
describe("Unauthenticated requests return 401", () => {
  for (const tool of AI_TOOLS) {
    it(`${tool.label}: no auth -> 401`, async () => {
      const fixturePath = fixtures.image.formats("png");
      if (!existsSync(fixturePath)) return;

      const buffer = readFileSync(fixturePath);
      const fmt = FORMAT_SAMPLES.find((f) => f.file === "sample.png")!;
      const { body: payload, contentType } = buildAiPayload(
        fmt,
        tool,
        buffer,
        tool.requiresMask ? maskBuffer : undefined,
      );

      const res = await app.inject({
        method: "POST",
        url: getToolUrl(tool.id),
        headers: {
          // No authorization header
          "content-type": contentType,
        },
        body: payload,
      });

      expect(res.statusCode).toBe(401);
    });
  }
});

// ---------------------------------------------------------------------------
// Invalid settings -> 400 for tools with settings validation
// ---------------------------------------------------------------------------
describe("Invalid settings return 400", () => {
  // Only test tools where we have specifically invalid settings that
  // should trigger Zod validation errors
  const TOOLS_WITH_INVALID_SETTINGS = AI_TOOLS.filter(
    (t) => Object.keys(t.invalidSettings).length > 0,
  );

  for (const tool of TOOLS_WITH_INVALID_SETTINGS) {
    it(`${tool.label}: invalid settings -> 400`, async () => {
      const fixturePath = fixtures.image.formats("png");
      if (!existsSync(fixturePath)) return;

      const buffer = readFileSync(fixturePath);

      const fields: Array<{
        name: string;
        filename?: string;
        contentType?: string;
        content: Buffer | string;
      }> = [
        {
          name: "file",
          filename: "sample.png",
          contentType: "image/png",
          content: buffer,
        },
        {
          name: "settings",
          content: JSON.stringify(tool.invalidSettings),
        },
      ];

      // erase-object needs a mask
      if (tool.requiresMask) {
        fields.push({
          name: "mask",
          filename: "mask.png",
          contentType: "image/png",
          content: maskBuffer,
        });
      }

      const { body: payload, contentType } = createMultipartPayload(fields);

      const res = await app.inject({
        method: "POST",
        url: getToolUrl(tool.id),
        headers: {
          authorization: `Bearer ${adminToken}`,
          "content-type": contentType,
        },
        body: payload,
      });

      // Tools with 501 guard may return 501 before validating settings
      if (tool.has501Guard) {
        expect([400, 501]).toContain(res.statusCode);
      } else {
        expect(res.statusCode).toBe(400);
      }

      const body = JSON.parse(res.body);
      expect(body.error).toBeDefined();
      expect(typeof body.error).toBe("string");
    });
  }
});

// ---------------------------------------------------------------------------
// Erase-object specific: missing mask -> 400
// ---------------------------------------------------------------------------
describe("Erase-object missing mask returns 400", () => {
  it("erase-object without mask file -> 400 or 501", async () => {
    const fixturePath = fixtures.image.formats("png");
    if (!existsSync(fixturePath)) return;

    const buffer = readFileSync(fixturePath);
    const { body: payload, contentType } = createMultipartPayload([
      {
        name: "file",
        filename: "sample.png",
        contentType: "image/png",
        content: buffer,
      },
    ]);

    const res = await app.inject({
      method: "POST",
      url: "/api/v1/tools/image/erase-object",
      headers: {
        authorization: `Bearer ${adminToken}`,
        "content-type": contentType,
      },
      body: payload,
    });

    // 501 if AI not installed, 400 if validation catches missing mask
    expect([400, 501]).toContain(res.statusCode);

    const body = JSON.parse(res.body);
    expect(body.error).toBeDefined();
    expect(typeof body.error).toBe("string");
  });
});

// ---------------------------------------------------------------------------
// Content-aware-resize specific: missing dimensions -> 400
// ---------------------------------------------------------------------------
describe("Content-aware-resize without dimensions returns 400", () => {
  it("content-aware-resize without width/height/square -> 400", async () => {
    const fixturePath = fixtures.image.formats("png");
    if (!existsSync(fixturePath)) return;

    const buffer = readFileSync(fixturePath);
    const { body: payload, contentType } = createMultipartPayload([
      {
        name: "file",
        filename: "sample.png",
        contentType: "image/png",
        content: buffer,
      },
      {
        name: "settings",
        content: JSON.stringify({}),
      },
    ]);

    const res = await app.inject({
      method: "POST",
      url: "/api/v1/tools/image/content-aware-resize",
      headers: {
        authorization: `Bearer ${adminToken}`,
        "content-type": contentType,
      },
      body: payload,
    });

    expect(res.statusCode).toBe(400);

    const body = JSON.parse(res.body);
    expect(body.error).toBeDefined();
    expect(typeof body.error).toBe("string");
  });
});

// ---------------------------------------------------------------------------
// Malformed JSON settings -> 400 for AI tools
// ---------------------------------------------------------------------------
describe("Malformed JSON settings return 400", () => {
  // Test a representative subset of tools (one per pattern)
  const REPRESENTATIVE_TOOLS = AI_TOOLS.filter(
    (t) => !t.requiresMask && t.id !== "passport-photo",
  ).slice(0, 5);

  for (const tool of REPRESENTATIVE_TOOLS) {
    it(`${tool.label}: malformed JSON settings -> 400 or 501`, async () => {
      const fixturePath = fixtures.image.formats("png");
      if (!existsSync(fixturePath)) return;

      const buffer = readFileSync(fixturePath);
      const { body: payload, contentType } = createMultipartPayload([
        {
          name: "file",
          filename: "sample.png",
          contentType: "image/png",
          content: buffer,
        },
        {
          name: "settings",
          content: "{ this is not valid json }}}",
        },
      ]);

      const res = await app.inject({
        method: "POST",
        url: getToolUrl(tool.id),
        headers: {
          authorization: `Bearer ${adminToken}`,
          "content-type": contentType,
        },
        body: payload,
      });

      // 501 if AI not installed, 400 for malformed JSON
      if (tool.has501Guard) {
        expect([400, 501]).toContain(res.statusCode);
      } else {
        expect(res.statusCode).toBe(400);
      }

      const body = JSON.parse(res.body);
      expect(body.error).toBeDefined();
      expect(typeof body.error).toBe("string");
    });
  }
});
