// #region DictionaryDetailModelTests [C:3] [TYPE Module] [SEMANTICS test,model,dictionary-detail]
// @BRIEF L1 unit tests for DictionaryDetailModel — no DOM render, mocks API + toast + i18n.
// @RELATION BINDS_TO -> [DictionaryDetailModel]
// @TEST_INVARIANT: expand-collapse -> VERIFIED_BY: [test_toggle_expand, test_toggle_collapse]
// @TEST_INVARIANT: import-preview-cleared -> VERIFIED_BY: [test_close_import_clears]
// @TEST_EDGE: missing_field -> save with empty form fields
// @TEST_EDGE: invalid_type -> API returns unexpected shape on load
// @TEST_EDGE: external_fail -> API throws on all CRUD operations
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("$lib/api.js", () => ({
  api: { fetchApi: vi.fn(), postApi: vi.fn(), requestApi: vi.fn(), getAllowedLanguages: vi.fn() },
}));
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/i18n/index.svelte.js", () => ({
  t: {
    subscribe: (fn: any) => { fn({}); return () => {}; },
    translate: { dictionaries: { entry_saved: "Saved", entry_added: "Added", entry_deleted: "Deleted", confirm_delete: "Delete?", import_success: "Imported" } },
  },
}));
vi.mock("$lib/i18n/languages.js", () => ({
  ALL_LANGUAGES: [
    { code: "en", name: "English" },
    { code: "ru", name: "Russian" },
    { code: "und", name: "Undefined" },
  ],
}));

import { DictionaryDetailModel } from "../DictionaryDetailModel.svelte.ts";
import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts.svelte.js";

describe("DictionaryDetailModel — L1 invariants (no render)", () => {
  let model: DictionaryDetailModel;

  beforeEach(() => {
    vi.clearAllMocks();
    model = new DictionaryDetailModel();
  });

  // ═══════════════════════════════════════════════════════════════
  // Initial state
  // ═══════════════════════════════════════════════════════════════
  describe("initial state", () => {
    it("starts with default values", () => {
      expect(model.appState).toBe("loading");
      expect(model.dictionary).toBeNull();
      expect(model.entries).toEqual([]);
      expect(model.totalEntries).toBe(0);
      expect(model.entryPage).toBe(1);
      expect(model.expandedEntryId).toBeNull();
      expect(model.importPreview).toEqual([]);
      expect(model.importErrors).toEqual([]);
      expect(model.importResult).toBeNull();
      expect(model.isImporting).toBe(false);
      expect(model.showImportForm).toBe(false);
      expect(model.dictionaryId).toBe("");
    });

    it("derived isLoading equals appState === 'loading'", () => {
      expect(model.isLoading).toBe(true);
      model.appState = "idle";
      expect(model.isLoading).toBe(false);
    });

    it("derived isEditing equals appState === 'editing'", () => {
      model.appState = "editing";
      expect(model.isEditing).toBe(true);
      expect(model.isSaving).toBe(false);
    });

    it("derived isSaving equals appState === 'saving'", () => {
      model.appState = "saving";
      expect(model.isSaving).toBe(true);
      expect(model.isEditing).toBe(false);
    });

    it("derived languageOptions filters by allowedLanguages", () => {
      model.allowedLanguages = ["en"];
      expect(model.languageOptions.length).toBe(1);
      expect(model.languageOptions[0].code).toBe("en");
    });

    it("derived languageOptions returns all when allowedLanguages is empty", () => {
      model.allowedLanguages = [];
      expect(model.languageOptions.length).toBe(3);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // loadAllowedLanguages
  // ═══════════════════════════════════════════════════════════════
  describe("loadAllowedLanguages", () => {
    it("loads allowed languages on success", async () => {
      vi.mocked(api.getAllowedLanguages).mockResolvedValue(["en", "ru"]);
      await model.loadAllowedLanguages();
      expect(model.allowedLanguages).toEqual(["en", "ru"]);
    });

    it("sets empty array on API failure", async () => {
      vi.mocked(api.getAllowedLanguages).mockRejectedValue(new Error("Fail"));
      await model.loadAllowedLanguages();
      expect(model.allowedLanguages).toEqual([]);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Derived state edge cases
  // ═══════════════════════════════════════════════════════════════
  describe("derived state — isEditing false path", () => {
    it("isEditing is false when appState is not 'editing'", () => {
      model.appState = "idle";
      expect(model.isEditing).toBe(false);
      model.appState = "loading";
      expect(model.isEditing).toBe(false);
      model.appState = "saving";
      expect(model.isEditing).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // loadDictionary
  // ═══════════════════════════════════════════════════════════════
  describe("loadDictionary", () => {
    it("does nothing when dictionaryId is empty", async () => {
      await model.loadDictionary();
      expect(api.requestApi).not.toHaveBeenCalled();
    });

    it("loads dictionary and entries on success", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValueOnce({ id: "dict-1", name: "My Dictionary" });
      vi.mocked(api.getAllowedLanguages).mockResolvedValue([]);
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [{ id: "e1", source_term: "hello", target_term: "привет" }], total: 1 });

      await model.loadDictionary();

      expect(api.requestApi).toHaveBeenCalledWith("/translate/dictionaries/dict-1");
      expect(model.dictionary).toEqual({ id: "dict-1", name: "My Dictionary" });
      expect(model.appState).toBe("idle");
    });

    it("sets appState idle on API failure", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Not found"));

      await model.loadDictionary();

      expect(model.appState).toBe("idle");
      expect(addToast).toHaveBeenCalledWith("Not found", "error");
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // loadEntries
  // ═══════════════════════════════════════════════════════════════
  describe("loadEntries", () => {
    it("loads entries on success", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValue({
        items: [{ id: "e1", source_term: "hello", target_term: "привет" }],
        total: 1,
      });

      await model.loadEntries();

      expect(model.entries).toHaveLength(1);
      expect(model.entries[0].source_term).toBe("hello");
      expect(model.totalEntries).toBe(1);
    });

    it("falls back to entries field when items is absent", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValue({
        entries: [{ id: "e2", source_term: "bye", target_term: "пока" }],
        total: 1,
      });

      await model.loadEntries();

      expect(model.entries).toHaveLength(1);
      expect(model.entries[0].source_term).toBe("bye");
    });

    it("shows toast on API failure", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Load failed"));

      await model.loadEntries();

      expect(addToast).toHaveBeenCalledWith("Load failed", "error");
    });

    it("appends source_language filter param when set", async () => {
      model.dictionaryId = "dict-1";
      model.filterSourceLanguage = "en";
      vi.mocked(api.requestApi).mockResolvedValue({ items: [], total: 0 });

      await model.loadEntries();

      expect(api.requestApi).toHaveBeenCalledWith(
        expect.stringContaining("source_language=en"),
      );
    });

    it("handles response without items or entries field (falls back to [])", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValue({ other_field: true });

      await model.loadEntries();

      expect(model.entries).toEqual([]);
      expect(model.totalEntries).toBe(0);
    });

    it("handles response without total field (falls back to 0)", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValue({ items: [{ id: "e1", source_term: "a", target_term: "b" }] });

      await model.loadEntries();

      expect(model.totalEntries).toBe(0);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Expand / collapse entry
  // ═══════════════════════════════════════════════════════════════
  describe("toggleExpand", () => {
    it("sets expandedEntryId when clicking a new entry", () => {
      model.toggleExpand("entry-1");
      expect(model.expandedEntryId).toBe("entry-1");
      expect(model.expandedEditing).toBe(false);
    });

    it("collapses when clicking the same entry", () => {
      model.expandedEntryId = "entry-1";
      model.toggleExpand("entry-1");
      expect(model.expandedEntryId).toBeNull();
    });

    it("switches to a different entry", () => {
      model.expandedEntryId = "entry-1";
      model.toggleExpand("entry-2");
      expect(model.expandedEntryId).toBe("entry-2");
      expect(model.expandedEditing).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Edit flow
  // ═══════════════════════════════════════════════════════════════
  describe("startEdit / cancelEdit", () => {
    it("startEdit populates form and sets editing state", () => {
      model.startEdit({
        id: "e1",
        source_term: "hello",
        target_term: "привет",
        source_language: "en",
        target_language: "ru",
        context_notes: "Greeting",
        context_data: { formal: false },
        usage_notes: "Common",
        is_regex: false,
      });

      expect(model.editEntryId).toBe("e1");
      expect(model.editForm.source_term).toBe("hello");
      expect(model.editForm.target_term).toBe("привет");
      expect(model.editForm.context_notes).toBe("Greeting");
      expect(model.editContextDataRaw).toBe('{\n  "formal": false\n}');
      expect(model.appState).toBe("editing");
    });

    it("cancelEdit resets editing state", () => {
      model.editEntryId = "e1";
      model.appState = "editing";
      model.cancelEdit();
      expect(model.editEntryId).toBeNull();
      expect(model.appState).toBe("idle");
    });
  });

  describe("saveEntry", () => {
    it("does nothing when editEntryId is null", async () => {
      await model.saveEntry();
      expect(api.requestApi).not.toHaveBeenCalled();
    });

    it("saves entry and reloads on success", async () => {
      model.editEntryId = "e1";
      model.dictionaryId = "dict-1";
      model.editForm = { source_term: "hello", target_term: "привет", source_language: "en", target_language: "ru", context_notes: "", context_data: null, usage_notes: "", is_regex: false };
      vi.mocked(api.requestApi).mockResolvedValueOnce({});
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.saveEntry();

      expect(api.requestApi).toHaveBeenCalledWith(
        "/translate/dictionaries/dict-1/entries/e1",
        "PUT",
        expect.objectContaining({ source_term: "hello" }),
      );
      expect(addToast).toHaveBeenCalledWith("Saved", "success");
      expect(model.appState).toBe("idle");
    });

    it("sets appState back to editing on API failure", async () => {
      model.editEntryId = "e1";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Save failed"));

      await model.saveEntry();

      expect(model.appState).toBe("editing");
      expect(addToast).toHaveBeenCalledWith("Save failed", "error");
    });

    it("parses editContextDataRaw as JSON when present", async () => {
      model.editEntryId = "e1";
      model.dictionaryId = "dict-1";
      model.editContextDataRaw = '{"key": "value"}';
      vi.mocked(api.requestApi).mockResolvedValueOnce({});
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.saveEntry();

      expect(api.requestApi).toHaveBeenCalledWith(
        expect.any(String),
        "PUT",
        expect.objectContaining({ context_data: { key: "value" } }),
      );
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Add entry
  // ═══════════════════════════════════════════════════════════════
  describe("addEntry", () => {
    it("posts entry and reloads on success", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValueOnce({});
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.addEntry();

      expect(api.requestApi).toHaveBeenCalledWith(
        "/translate/dictionaries/dict-1/entries",
        "POST",
        expect.objectContaining({ source_term: "" }),
      );
      expect(model.showAddForm).toBe(false);
      expect(addToast).toHaveBeenCalledWith("Added", "success");
      expect(model.isAdding).toBe(false);
    });

    it("shows toast on API failure", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Add failed"));

      await model.addEntry();

      expect(addToast).toHaveBeenCalledWith("Add failed", "error");
      expect(model.isAdding).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Delete entry
  // ═══════════════════════════════════════════════════════════════
  describe("deleteEntry", () => {
    beforeEach(() => {
      // confirm dialog returns true by default in jsdom
      vi.spyOn(window, "confirm").mockReturnValue(true);
    });

    it("does nothing when user cancels confirm", async () => {
      vi.spyOn(window, "confirm").mockReturnValue(false);
      await model.deleteEntry("e1");
      expect(api.requestApi).not.toHaveBeenCalled();
    });

    it("deletes entry on confirm and reloads", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValueOnce({});
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.deleteEntry("e1");

      expect(api.requestApi).toHaveBeenCalledWith(
        "/translate/dictionaries/dict-1/entries/e1",
        "DELETE",
      );
      expect(addToast).toHaveBeenCalledWith("Deleted", "success");
    });

    it("shows toast on API failure", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Delete failed"));

      await model.deleteEntry("e1");

      expect(addToast).toHaveBeenCalledWith("Delete failed", "error");
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // startEdit — edge cases
  // ═══════════════════════════════════════════════════════════════
  describe("startEdit — missing optional fields", () => {
    it("handles entry without context_data", () => {
      model.startEdit({
        id: "e1",
        source_term: "hello",
        target_term: "привет",
      });
      expect(model.editContextDataRaw).toBe("");
    });

    it("handles entry with context_data explicitly null", () => {
      model.startEdit({
        id: "e1",
        source_term: "hello",
        target_term: "привет",
        context_data: null,
      } as any);
      expect(model.editContextDataRaw).toBe("");
    });

    it("handles entry with missing optional string fields", () => {
      model.startEdit({
        id: "e1",
        source_term: "hello",
        target_term: "привет",
        source_language: undefined,
        target_language: undefined,
        context_notes: undefined,
        usage_notes: undefined,
        is_regex: undefined,
      } as any);
      expect(model.editForm.source_language).toBe("");
      expect(model.editForm.target_language).toBe("");
      expect(model.editForm.context_notes).toBe("");
      expect(model.editForm.usage_notes).toBe("");
      expect(model.editForm.is_regex).toBe(false);
    });

    it("handles entry with all undefined optional fields", () => {
      model.startEdit({
        id: "e1",
        source_term: "test",
        target_term: "тест",
        source_language: undefined as any,
        target_language: undefined as any,
      });
      expect(model.editForm.source_language).toBe("");
      expect(model.editForm.target_language).toBe("");
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // saveEntry — edge cases
  // ═══════════════════════════════════════════════════════════════
  describe("saveEntry — edge cases", () => {
    it("handles invalid JSON in editContextDataRaw (JSON.parse catch)", async () => {
      model.editEntryId = "e1";
      model.dictionaryId = "dict-1";
      model.editForm = { source_term: "hello", target_term: "hi", source_language: "en", target_language: "fr", context_notes: "", context_data: { original: true }, usage_notes: "", is_regex: false };
      model.editContextDataRaw = "not-valid-json{";
      vi.mocked(api.requestApi).mockResolvedValueOnce({});
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.saveEntry();

      // JSON.parse throws, catch keeps context_data from editForm (unchanged)
      expect(api.requestApi).toHaveBeenCalledWith(
        expect.any(String),
        "PUT",
        expect.objectContaining({ context_data: { original: true } }),
      );
    });

    it("handles empty editContextDataRaw (falsy branch)", async () => {
      model.editEntryId = "e1";
      model.dictionaryId = "dict-1";
      model.editForm = { source_term: "hello", target_term: "hi", source_language: "en", target_language: "fr", context_notes: "", context_data: { preserved: true }, usage_notes: "", is_regex: false };
      model.editContextDataRaw = "";
      vi.mocked(api.requestApi).mockResolvedValueOnce({});
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.saveEntry();

      // editContextDataRaw is falsy, so JSON.parse is never attempted
      // context_data stays as copied from editForm
      expect(api.requestApi).toHaveBeenCalledWith(
        expect.any(String),
        "PUT",
        expect.objectContaining({ context_data: { preserved: true } }),
      );
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Non-Error exceptions
  // ═══════════════════════════════════════════════════════════════
  describe("non-Error exception handling", () => {
    it("loadDictionary handles string rejection (non-Error)", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue("Not found string");
      await model.loadDictionary();
      expect(model.appState).toBe("idle");
      expect(addToast).toHaveBeenCalledWith("Failed to load dictionary", "error");
    });

    it("loadEntries handles string rejection (non-Error)", async () => {
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue("Load error");
      await model.loadEntries();
      expect(addToast).toHaveBeenCalledWith("Failed to load entries", "error");
    });

    it("deleteEntry handles string rejection (non-Error)", async () => {
      model.dictionaryId = "dict-1";
      vi.spyOn(window, "confirm").mockReturnValue(true);
      vi.mocked(api.requestApi).mockRejectedValue("Delete error");
      await model.deleteEntry("e1");
      expect(addToast).toHaveBeenCalledWith("Failed to delete entry", "error");
    });

    it("saveEntry handles string rejection (non-Error)", async () => {
      model.editEntryId = "e1";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue("Save error string");
      await model.saveEntry();
      expect(model.appState).toBe("editing");
      expect(addToast).toHaveBeenCalledWith("Failed to save entry", "error");
    });

    it("previewImport handles string rejection (non-Error)", async () => {
      model.importContent = "a,b\n1,2";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue("Preview error string");
      await model.previewImport();
      expect(model.appState).toBe("idle");
      expect(addToast).toHaveBeenCalledWith("Preview failed", "error");
    });

    it("executeImport handles string rejection (non-Error)", async () => {
      model.importContent = "a,b\n1,2";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue("Import error string");
      await model.executeImport();
      expect(addToast).toHaveBeenCalledWith("Import failed", "error");
      expect(model.isImporting).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // Import flow
  // ═══════════════════════════════════════════════════════════════
  describe("previewImport", () => {
    it("does nothing when importContent is empty", async () => {
      await model.previewImport();
      expect(api.requestApi).not.toHaveBeenCalled();
    });

    it("sets import_preview state on success", async () => {
      model.importContent = "a,b\n1,2";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValue({ preview: [{ row: 1 }], errors: [] });

      await model.previewImport();

      expect(model.appState).toBe("import_preview");
      expect(model.importPreview).toEqual([{ row: 1 }]);
      expect(model.importErrors).toEqual([]);
    });

    it("sets appState idle on API failure", async () => {
      model.importContent = "a,b\n1,2";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Preview failed"));

      await model.previewImport();

      expect(model.appState).toBe("idle");
      expect(addToast).toHaveBeenCalledWith("Preview failed", "error");
    });
  });

  describe("executeImport", () => {
    it("imports entries and shows success", async () => {
      model.importContent = "a,b\n1,2";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockResolvedValueOnce({ imported: 5, skipped: 0 });
      vi.mocked(api.requestApi).mockResolvedValueOnce({ items: [], total: 0 });

      await model.executeImport();

      expect(model.importResult).toEqual({ imported: 5, skipped: 0 });
      expect(model.showImportForm).toBe(false);
      expect(addToast).toHaveBeenCalledWith("Imported", "success");
      expect(model.isImporting).toBe(false);
    });

    it("shows toast on API failure", async () => {
      model.importContent = "a,b\n1,2";
      model.dictionaryId = "dict-1";
      vi.mocked(api.requestApi).mockRejectedValue(new Error("Import failed"));

      await model.executeImport();

      expect(addToast).toHaveBeenCalledWith("Import failed", "error");
      expect(model.isImporting).toBe(false);
    });
  });
});
// #endregion DictionaryDetailModelTests
