/**
 * Tests for `JobSettingsPanel` — regression coverage для issue #1239.
 *
 * Bug: правки JSON `extra_config` молча терялись, если textarea свёрнута при
 * сохранении. handleSave парсил `draft.extra_config_text` только когда
 * `expanded[job_type] === true` — после клика «▼ скрыть» PUT уходил без
 * `extra_config`, бэкенд (PATCH-семантика `if extra_config is not None`) не
 * трогал поле и возвращал старый JSON, onSuccess перетирал draft через
 * `toRowDraft(data)`, а toast «сохранено» давал ложное подтверждение.
 *
 * Fix: сравнивать `draft.extra_config_text` с `JSON.stringify(server
 * extra_config, null, 2)` и слать `extra_config` всякий раз, когда draft
 * отличается от server-baseline — независимо от `expanded[]`.
 */
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { JobSettingsPanel } from "../JobSettingsPanel";

// Mock apiFetch (используется и для GET listing, и для PUT update). Возвращаем
// разные ответы в зависимости от method'а: GET → server list, PUT → echo
// updated row (имитируя backend, который сохраняет переданный extra_config).
type JobSettingRead = {
  job_type: string;
  enabled: boolean;
  queue_name: string;
  cron_schedule: string | null;
  rate_ms: number | null;
  max_retries: number;
  max_concurrency: number;
  extra_config: Record<string, unknown>;
  updated_at: string;
  updated_by: string | null;
  description: string | null;
};

const SERVER_ROW: JobSettingRead = {
  job_type: "noise_loader",
  enabled: true,
  queue_name: "default",
  cron_schedule: "*/15 * * * *",
  rate_ms: null,
  max_retries: 3,
  max_concurrency: 1,
  extra_config: { timeout_s: 30, retries: 2 },
  updated_at: "2026-06-01T10:00:00Z",
  updated_by: "admin",
  description: "Загрузчик шумовых карт",
};

let putBodies: Array<Record<string, unknown>> = [];

const apiFetchMock = vi.fn(
  (path: string, init?: RequestInit): Promise<unknown> => {
    if (!init || init.method === undefined) {
      // GET /api/v1/admin/jobs/settings
      return Promise.resolve([SERVER_ROW]);
    }
    if (init.method === "PUT") {
      const body = JSON.parse(
        typeof init.body === "string" ? init.body : "{}",
      ) as Record<string, unknown>;
      putBodies.push(body);
      // Эмуляция PATCH-семантики бэкенда: если поле не передано — сохраняем
      // старое серверное значение. Именно это поведение делает баг «молчаливым»
      // (toast «сохранено» появляется, но extra_config не изменился).
      const updated: JobSettingRead = {
        ...SERVER_ROW,
        enabled:
          typeof body.enabled === "boolean" ? body.enabled : SERVER_ROW.enabled,
        cron_schedule:
          typeof body.cron_schedule === "string" || body.cron_schedule === null
            ? (body.cron_schedule as string | null)
            : SERVER_ROW.cron_schedule,
        rate_ms:
          typeof body.rate_ms === "number" || body.rate_ms === null
            ? (body.rate_ms as number | null)
            : SERVER_ROW.rate_ms,
        max_retries:
          typeof body.max_retries === "number"
            ? body.max_retries
            : SERVER_ROW.max_retries,
        max_concurrency:
          typeof body.max_concurrency === "number"
            ? body.max_concurrency
            : SERVER_ROW.max_concurrency,
        extra_config:
          body.extra_config !== undefined
            ? (body.extra_config as Record<string, unknown>)
            : SERVER_ROW.extra_config,
        updated_at: "2026-06-13T12:00:00Z",
      };
      void path;
      return Promise.resolve(updated);
    }
    return Promise.reject(new Error(`Unexpected fetch ${init.method} ${path}`));
  },
);

vi.mock("@/lib/api", () => ({
  apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init),
}));

function renderPanel() {
  const client = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });
  return render(
    <QueryClientProvider client={client}>
      <JobSettingsPanel />
    </QueryClientProvider>,
  );
}

beforeEach(() => {
  apiFetchMock.mockClear();
  putBodies = [];
});

afterEach(() => {
  vi.clearAllMocks();
});

describe("JobSettingsPanel — extra_config persistence (#1239)", () => {
  it("отправляет правленый JSON extra_config даже если textarea свёрнута на момент save", async () => {
    renderPanel();

    // Ждём появления строки (загрузка settings).
    await screen.findByText("noise_loader");

    // 1. Раскрыть JSON textarea.
    const toggle = screen.getByRole("button", { name: /JSON|скрыть/ });
    await userEvent.click(toggle);

    // 2. Найти textarea, очистить и ввести новый JSON.
    const textarea = (await screen.findByDisplayValue(
      /timeout_s/,
    )) as HTMLTextAreaElement;
    await userEvent.clear(textarea);
    await userEvent.type(textarea, '{{"timeout_s": 99, "retries": 5}');

    // 3. Свернуть textarea (главное условие бага — «▼ скрыть»).
    await userEvent.click(screen.getByRole("button", { name: /скрыть/ }));

    // 4. Save.
    await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));

    // 5. PUT должен содержать extra_config с новым значением — а НЕ
    //    отсутствовать (как было до фикса).
    await waitFor(() => {
      expect(putBodies).toHaveLength(1);
    });
    const sent = putBodies[0];
    expect(sent).toHaveProperty("extra_config");
    expect(sent.extra_config).toEqual({ timeout_s: 99, retries: 5 });
  });

  it("не шлёт extra_config, если JSON-текст не менялся (избегаем лишних writes)", async () => {
    renderPanel();
    await screen.findByText("noise_loader");

    // Поменять только enabled (чекбокс), JSON не трогать.
    const checkbox = screen.getByRole("checkbox");
    await userEvent.click(checkbox);

    await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));

    await waitFor(() => {
      expect(putBodies).toHaveLength(1);
    });
    expect(putBodies[0]).not.toHaveProperty("extra_config");
    expect(putBodies[0]).toHaveProperty("enabled", false);
  });

  it("показывает ошибку и раскрывает строку, если JSON невалиден (даже при collapsed)", async () => {
    renderPanel();
    await screen.findByText("noise_loader");

    // Раскрыть → испортить JSON → свернуть → save.
    await userEvent.click(screen.getByRole("button", { name: /JSON|скрыть/ }));
    const textarea = (await screen.findByDisplayValue(
      /timeout_s/,
    )) as HTMLTextAreaElement;
    await userEvent.clear(textarea);
    await userEvent.type(textarea, "{{not json");
    await userEvent.click(screen.getByRole("button", { name: /скрыть/ }));

    await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));

    // Ошибка должна появиться, textarea — открыться.
    expect(await screen.findByText(/Невалидный JSON/)).toBeInTheDocument();
    // PUT НЕ должен был уйти.
    expect(putBodies).toHaveLength(0);
  });
});
