// frontend/src/lib/models/__tests__/GitManagerModel.test.ts
// #region GitManagerModelTests [C:3] [TYPE Module] [SEMANTICS test,model,git-manager]
// @BRIEF L1 unit tests for GitManagerModel — no DOM render, mocks all API + toast + i18n.
// @RELATION BINDS_TO -> [GitManagerModel]
// @TEST_INVARIANT: derived-computations -> VERIFIED_BY: [test_hasWorkspaceChanges_true_when_changes, test_changedFilesCount_aggregates_all_categories, test_derived_no_workspaceStatus]
// @TEST_INVARIANT: loading-state-lifecycle -> VERIFIED_BY: [test_checkStatus_sets_checkingStatus_lifecycle, test_handleCommit_sets_committing_lifecycle]
// @TEST_INVARIANT: mutual-exclusion -> VERIFIED_BY: [test_mutual_exclusion_defaults_all_false]
// @TEST_INVARIANT: error-handling -> VERIFIED_BY: [test_handleCommit_sets_gitError_on_api_failure, test_handleCommit_clears_gitError_before_operation]
// @TEST_INVARIANT: commit-calls-api -> VERIFIED_BY: [test_handleCommit_calls_gitService_commit_with_correct_args, test_handleCommit_calls_push_when_autoPushAfterCommit]
// @TEST_INVARIANT: promote-calls-api -> VERIFIED_BY: [test_handlePromote_calls_gitService_promote, test_handlePromote_validates_branches]
// @TEST_INVARIANT: pull-calls-api -> VERIFIED_BY: [test_handlePull_calls_gitService_pull]
// @TEST_INVARIANT: clearGitError -> VERIFIED_BY: [test_clearGitError_resets_both_fields]
// @TEST_EDGE: missing_field -> empty commitMessage blocks handleCommit
// @TEST_EDGE: invalid_type -> numeric dashboard id blocks checkStatus
// @TEST_EDGE: external_fail -> API throws on handlePull
import { describe, it, expect, vi, beforeEach } from "vitest";

// ── Mocks ────────────────────────────────────────────────────────
vi.mock("../../../services/gitService.js", () => ({
  gitService: { getConfigs: vi.fn(), getBranches: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), commit: vi.fn(), push: vi.fn(), pull: vi.fn(), promote: vi.fn(), sync: vi.fn(), getMergeStatus: vi.fn(), getMergeConflicts: vi.fn(), resolveMergeConflicts: vi.fn(), abortMerge: vi.fn(), continueMerge: vi.fn(), getRepositoryBinding: vi.fn(), initRepository: vi.fn(), createRemoteRepository: vi.fn() },
}));
vi.mock("$lib/api.js", () => ({ api: { getEnvironmentsList: vi.fn(), postApi: vi.fn() } }));
vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn() }));
vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) }));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("svelte/store", () => ({ get: vi.fn(() => ({ git: {} })) }));

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

interface WorkspaceStatusFixture { is_dirty?: boolean; staged_files?: string[]; modified_files?: string[]; untracked_files?: string[]; current_branch?: string; }

function createModel(overrides: Partial<{ dashboardId: string; envId: string | null; dashboardTitle: string }> = {}): GitManagerModel {
  return new GitManagerModel({ dashboardId: "test-dashboard", envId: "env-123", dashboardTitle: "Test Dashboard", ...overrides });
}
function makeWs(overrides: Partial<WorkspaceStatusFixture> = {}): WorkspaceStatusFixture {
  return { is_dirty: false, staged_files: [], modified_files: [], untracked_files: [], current_branch: "main", ...overrides };
}

describe("GitManagerModel — L1 invariants (no render)", () => {
  let model: GitManagerModel;
  beforeEach(() => { vi.clearAllMocks(); model = createModel(); });

  describe("constructor", () => {
    it("stores props", () => { expect(model.dashboardId).toBe("test-dashboard"); expect(model.envId).toBe("env-123"); expect(model.dashboardTitle).toBe("Test Dashboard"); });
    it("defaults when called empty", () => { const m = new GitManagerModel(); expect(m.dashboardId).toBe(""); expect(m.envId).toBeNull(); });
  });

  describe("derived state", () => {
    it("hasWorkspaceChanges false when status null", () => { model.workspaceStatus = null; expect(model.hasWorkspaceChanges).toBe(false); });
    it("hasWorkspaceChanges true when staged", () => { model.workspaceStatus = makeWs({ staged_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
    it("hasWorkspaceChanges true when modified", () => { model.workspaceStatus = makeWs({ modified_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
    it("hasWorkspaceChanges true when untracked", () => { model.workspaceStatus = makeWs({ untracked_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
    it("hasWorkspaceChanges false when empty", () => { model.workspaceStatus = makeWs(); expect(model.hasWorkspaceChanges).toBe(false); });
    it("changedFilesCount aggregates all", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); });
    it("changedFilesCount 0 when null", () => { model.workspaceStatus = null; expect(model.changedFilesCount).toBe(0); });
    it("changedFilesCount 0 when empty", () => { model.workspaceStatus = makeWs(); expect(model.changedFilesCount).toBe(0); });
    it("resolvedEnvId returns envId", () => { model.envId = "env-456"; expect(model.resolvedEnvId).toBe("env-456"); });
    it("hasOriginConfigMismatch false when same host", () => {
      model.repositoryBindingRemoteUrl = "https://git.example.com/repo.git";
      model.repositoryConfigUrl = "https://git.example.com/config";
      expect(model.hasOriginConfigMismatch).toBe(false);
    });
    it("pushProviderLabel default is git", () => { expect(model.pushProviderLabel).toBe("git"); });
  });

  describe("mutual exclusion — defaults", () => {
    it("all false except checkingStatus", () => {
      expect(model.checkingStatus).toBe(true); expect(model.loading).toBe(false); expect(model.workspaceLoading).toBe(false);
      expect(model.committing).toBe(false); expect(model.isPulling).toBe(false); expect(model.isPushing).toBe(false);
      expect(model.generatingMessage).toBe(false); expect(model.promoting).toBe(false);
      expect(model.mergeResolveInProgress).toBe(false); expect(model.mergeAbortInProgress).toBe(false);
      expect(model.mergeContinueInProgress).toBe(false); expect(model.creatingRemoteRepo).toBe(false);
    });
  });

  describe("error handling", () => {
    it("clearGitError resets error", () => {
      model.gitError = { message: "Broke", status: 500, error_code: null, files: [], next_steps: [], detail: null };
      model.clearGitError();
      expect(model.gitError).toBeNull();
    });
    it("handleCommit sets gitError on failure", async () => {
      model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] });
      vi.mocked(gitService.commit).mockRejectedValue(new Error("Fail"));
      await model.handleCommit();
      expect(model.gitError).not.toBeNull();
    });
    it("handleCommit clears before operation", async () => {
      model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] });
      model.gitError = { message: "Old", status: 500, error_code: null, files: [], next_steps: [], detail: null };
      vi.mocked(gitService.commit).mockRejectedValue(new Error("New"));
      await model.handleCommit();
      expect(model.gitError!.message).toBe("New");
    });
  });

  describe("loading state lifecycle", () => {
    it("checkStatus transitions checkingStatus", async () => {
      vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
      expect(model.checkingStatus).toBe(true); await model.checkStatus(); expect(model.checkingStatus).toBe(false); expect(model.initialized).toBe(true);
    });
    it("checkStatus sets false on failure", async () => {
      vi.mocked(gitService.getBranches).mockRejectedValue(new Error("No repo"));
      await model.checkStatus(); expect(model.initialized).toBe(false); expect(model.checkingStatus).toBe(false);
    });
    it("handleCommit sets committing lifecycle", async () => {
      model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] });
      vi.mocked(gitService.commit).mockImplementation(() => new Promise((r) => setTimeout(r, 10)));
      const p = model.handleCommit(); expect(model.committing).toBe(true); await p; expect(model.committing).toBe(false);
    });
  });

  describe("guard conditions", () => {
    it("handleCommit returns early when message empty", async () => { await model.handleCommit(); expect(gitService.commit).not.toHaveBeenCalled(); });
    it("handleCommit returns early when no changes", async () => { model.commitMessage = "fix"; await model.handleCommit(); expect(gitService.commit).not.toHaveBeenCalled(); });
    it("handlePromote validates branches different", async () => {
      model.promoteFromBranch = "main"; model.promoteToBranch = "main";
      await model.handlePromote(); expect(gitService.promote).not.toHaveBeenCalled();
    });
    it("handlePromote validates reason in direct mode", async () => {
      model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "direct";
      await model.handlePromote(); expect(gitService.promote).not.toHaveBeenCalled();
    });
  });

  describe("handleCommit — API", () => {
    it("calls commit with correct args", async () => {
      model.commitMessage = "fix: x"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = false;
      vi.mocked(gitService.commit).mockResolvedValue({});
      await model.handleCommit();
      expect(gitService.commit).toHaveBeenCalledWith("test-dashboard", "fix: x", [], "env-123");
    });
    it("calls push when autoPushAfterCommit", async () => {
      model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = true;
      vi.mocked(gitService.commit).mockResolvedValue({}); vi.mocked(gitService.push).mockResolvedValue({});
      await model.handleCommit(); expect(gitService.push).toHaveBeenCalled();
    });
    it("does not push when autoPushAfterCommit false", async () => {
      model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = false;
      vi.mocked(gitService.commit).mockResolvedValue({});
      await model.handleCommit(); expect(gitService.push).not.toHaveBeenCalled();
    });
  });

  describe("handlePromote — API", () => {
    it("calls promote with correct args in MR mode", async () => {
      model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr";
      vi.mocked(gitService.promote).mockResolvedValue({ url: "https://example.com/mr/1" });
      await model.handlePromote();
      expect(gitService.promote).toHaveBeenCalledWith("test-dashboard", expect.objectContaining({ mode: "mr" }), "env-123");
    });
    it("calls promote with reason in direct mode", async () => {
      model.promoteFromBranch = "dev"; model.promoteToBranch = "preprod"; model.promoteMode = "direct"; model.promoteReason = "Hotfix";
      vi.mocked(gitService.promote).mockResolvedValue({});
      await model.handlePromote();
      expect(gitService.promote).toHaveBeenCalledWith("test-dashboard", expect.objectContaining({ reason: "Hotfix" }), "env-123");
    });
  });

  describe("handlePull — API", () => {
    it("calls pull with correct args", async () => {
      vi.mocked(gitService.pull).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
      await model.handlePull(); expect(gitService.pull).toHaveBeenCalledWith("test-dashboard", "env-123"); expect(model.isPulling).toBe(false);
    });
    it("opens merge dialog on 409", async () => {
      const err = new Error("Unfinished merge"); (err as any).status = 409; (err as any).detail = { error_code: "GIT_UNFINISHED_MERGE", message: "Merge in progress", repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", manual_commands: ["git merge --abort"], next_steps: ["Abort"] };
      vi.mocked(gitService.pull).mockRejectedValue(err); vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true });
      await model.handlePull(); expect(model.showUnfinishedMergeDialog).toBe(true);
    });
    it("sets gitError on non-merge failure", async () => {
      vi.mocked(gitService.pull).mockRejectedValue(new Error("Network"));
      await model.handlePull(); expect(model.gitError).not.toBeNull();
    });
  });

  describe("handlePush — API", () => {
    it("calls push", async () => {
      vi.mocked(gitService.push).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
      await model.handlePush(); expect(gitService.push).toHaveBeenCalledWith("test-dashboard", "env-123");
    });
    it("sets gitError on failure", async () => { vi.mocked(gitService.push).mockRejectedValue(new Error("Rejected")); await model.handlePush(); expect(model.gitError).not.toBeNull(); });
  });

  describe("handleSync — API", () => {
    it("calls sync", async () => {
      vi.mocked(gitService.sync).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue("");
      await model.handleSync(); expect(gitService.sync).toHaveBeenCalledWith("test-dashboard", "env-123", "env-123");
    });
    it("sets gitError on failure", async () => { vi.mocked(gitService.sync).mockRejectedValue(new Error("Sync fail")); await model.handleSync(); expect(model.gitError).not.toBeNull(); });
  });

  describe("handleGenerateMessage — API", () => {
    it("calls postApi and sets message", async () => {
      vi.mocked(api.postApi).mockResolvedValue({ message: "AI message" });
      await model.handleGenerateMessage();
      expect(api.postApi).toHaveBeenCalledWith(expect.stringContaining("/generate-message"), undefined, { suppressToast: true });
      expect(model.commitMessage).toBe("AI message"); expect(model.generatingMessage).toBe(false);
    });
    it("sets gitError on failure", async () => {
      vi.mocked(api.postApi).mockRejectedValue(new Error("Gen failed"));
      await model.handleGenerateMessage(); expect(model.gitError).not.toBeNull(); expect(model.generatingMessage).toBe(false);
    });
  });

  describe("checkStatus — guard paths", () => {
    it("blocks numeric dashboardId", async () => {
      model.dashboardId = "12345"; await model.checkStatus();
      expect(model.initialized).toBe(false); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Numeric ID"), "error");
    });
    it("sets false when no resolvedEnvId", async () => {
      const m = createModel({ envId: null }); await m.checkStatus();
      expect(m.initialized).toBe(false); expect(m.checkingStatus).toBe(false);
    });
  });

  describe("loadWorkspace", () => {
    it("returns early when not initialized", async () => { model.initialized = false; await model.loadWorkspace(); expect(gitService.getStatus).not.toHaveBeenCalled(); });
    it("loads workspace on success", async () => {
      model.initialized = true; vi.mocked(gitService.getStatus).mockResolvedValue(makeWs({ staged_files: ["x"] }));
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("staged"); vi.mocked(gitService.getDiff).mockResolvedValueOnce("unstaged");
      await model.loadWorkspace();
      expect(model.workspaceStatus).toEqual(makeWs({ staged_files: ["x"] })); expect(model.workspaceDiff).toContain("staged");
    });
    it("sets gitError on failure", async () => { model.initialized = true; vi.mocked(gitService.getStatus).mockRejectedValue(new Error("Fail")); await model.loadWorkspace(); expect(model.gitError).not.toBeNull(); });
  });

  describe("handleInit", () => {
    it("returns early when missing config or url", async () => { await model.handleInit(); expect(gitService.initRepository).not.toHaveBeenCalled(); });
    it("calls init on success", async () => {
      model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git"; vi.mocked(gitService.initRepository).mockResolvedValue({});
      await model.handleInit();
      expect(gitService.initRepository).toHaveBeenCalledWith("test-dashboard", "cfg-1", "https://example.com/repo.git", "env-123");
    });
  });

  describe("handleCreateRemoteRepo", () => {
    it("returns early when no config", async () => { await model.handleCreateRemoteRepo(); expect(gitService.createRemoteRepository).not.toHaveBeenCalled(); });
    it("handles 409 already exists error", async () => {
      model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test";
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
      const err = new Error("already exists"); (err as any).status = 409;
      vi.mocked(gitService.createRemoteRepository).mockRejectedValue(err);
      await model.handleCreateRemoteRepo();
      expect(addToast).toHaveBeenCalledWith("Already exists", "warning", 0);
      promptSpy.mockRestore();
    });
    it("handles other error via _setGitError", async () => {
      model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
      vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error"));
      await model.handleCreateRemoteRepo();
      expect(model.gitError).not.toBeNull();
      expect(model.creatingRemoteRepo).toBe(false);
      promptSpy.mockRestore();
    });
    it("returns early when user cancels prompt", async () => {
      model.configs = [{ id: "cfg-1", provider: "github" }];
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue(null);
      await model.handleCreateRemoteRepo();
      expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
      promptSpy.mockRestore();
    });
  });

  describe("handleInit — guard paths", () => {
    it("blocks when missing resolvedEnvId and non-numeric", async () => {
      model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git";
      const m = createModel({ envId: null }); m.selectedConfigId = "cfg-1"; m.remoteUrl = "https://example.com/repo.git";
      await m.handleInit();
      expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Environment must be selected"), "error");
      expect(gitService.initRepository).not.toHaveBeenCalled();
    });
  });

  describe("initialize", () => {
    it("loads configs and checks status", async () => {
      vi.mocked(gitService.getConfigs).mockResolvedValue([{ id: "cfg-1", provider: "github" }]);
      vi.mocked(gitService.getBranches).mockResolvedValue(["main"]);
      vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue("");
      vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]);
      vi.mocked(gitService.getRepositoryBinding).mockResolvedValue({ provider: "github", remote_url: "https://example.com/repo.git", config_id: "cfg-1" });
      await model.initialize();
      expect(model.configs).toHaveLength(1);
      expect(model.loading).toBe(false);
      expect(model.initialized).toBe(true);
    });
    it("handles config fetch error", async () => {
      vi.mocked(gitService.getConfigs).mockRejectedValue(new Error("Fail"));
      vi.mocked(gitService.getBranches).mockResolvedValue(["main"]);
      vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue("");
      vi.mocked(api.getEnvironmentsList).mockResolvedValue([]);
      await model.initialize();
      expect(model.configs).toEqual([]);
      expect(model.loading).toBe(false);
    });
  });

  describe("loadCurrentEnvironmentStage", () => {
    it("fetches env stage and sets defaults", async () => {
      vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]);
      await model.loadCurrentEnvironmentStage();
      expect(model.currentEnvStage).toBe("DEV");
    });
    it("handles error gracefully", async () => {
      const m = createModel({ envId: "env-99" });
      vi.mocked(api.getEnvironmentsList).mockRejectedValue(new Error("API error"));
      const { log } = await import("$lib/cot-logger");
      await m.loadCurrentEnvironmentStage();
      expect(log).toHaveBeenCalledWith(
        expect.any(String), "EXPLORE", expect.any(String), expect.any(Object), expect.any(String),
      );
    });
    it("returns early when no envId", async () => {
      const m = createModel({ envId: null });
      await m.loadCurrentEnvironmentStage();
      expect(api.getEnvironmentsList).not.toHaveBeenCalled();
    });
  });

  describe("openDeployModal", () => {
    it("opens modal when stage is not PROD", () => {
      model.currentEnvStage = "DEV";
      model.openDeployModal();
      expect(model.showDeployModal).toBe(true);
    });
    it("requires confirmation for PROD", () => {
      model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard";
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("wrong-slug");
      model.openDeployModal();
      expect(model.showDeployModal).toBe(false);
      expect(addToast).toHaveBeenCalledWith(expect.stringContaining("PROD"), "error");
      promptSpy.mockRestore();
    });
    it("opens modal when PROD confirmed correctly", () => {
      model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard";
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("test-dashboard");
      model.openDeployModal();
      expect(model.showDeployModal).toBe(true);
      promptSpy.mockRestore();
    });
  });

  describe("handleSync — numeric guard", () => {
    it("blocks numeric dashboardId", async () => {
      model.dashboardId = "12345"; await model.handleSync();
      expect(gitService.sync).not.toHaveBeenCalled();
    });
  });

  describe("derived — origin/config host mismatch", () => {
    it("hasOriginConfigMismatch true when hosts differ", () => {
      model.repositoryBindingRemoteUrl = "https://host1.example.com/repo.git";
      model.repositoryConfigUrl = "https://host2.example.com/config";
      expect(model.hasOriginConfigMismatch).toBe(true);
    });
    it("hasOriginConfigMismatch false when one host missing", () => {
      model.repositoryBindingRemoteUrl = "";
      model.repositoryConfigUrl = "https://host2.example.com/config";
      expect(model.hasOriginConfigMismatch).toBe(false);
    });
    it("originHost extracts http host from URL", () => {
      model.repositoryBindingRemoteUrl = "https://gitlab.com/org/repo.git";
      expect(model.originHost).toBe("gitlab.com");
    });
  });

  describe("merge recovery — extended", () => {
    it("loadMergeRecoveryState loads and updates context", async () => {
      vi.mocked(gitService.getMergeStatus).mockResolvedValue({
        has_unfinished_merge: true, repository_path: "/repo", git_dir: "/repo/.git",
        current_branch: "main", merge_head: "feature", merge_message_preview: "Merge msg", conflicts_count: 2,
      });
      model.unfinishedMergeContext = { nextSteps: ["Step 1"], commands: ["cmd1"] } as any;
      await model.loadMergeRecoveryState();
      expect(model.unfinishedMergeContext?.repositoryPath).toBe("/repo");
      expect(model.unfinishedMergeContext?.conflictsCount).toBe(2);
      expect(model.mergeRecoveryLoading).toBe(false);
    });
    it("loadMergeRecoveryState closes dialog when no unfinished merge", async () => {
      model.showUnfinishedMergeDialog = true;
      vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: false });
      await model.loadMergeRecoveryState();
      expect(model.showUnfinishedMergeDialog).toBe(false);
    });
    it("loadMergeRecoveryState sets gitError on failure", async () => {
      vi.mocked(gitService.getMergeStatus).mockRejectedValue(new Error("Merge error"));
      await model.loadMergeRecoveryState();
      expect(model.gitError).not.toBeNull();
    });
    it("handleOpenConflictResolver loads conflicts", async () => {
      vi.mocked(gitService.getMergeConflicts).mockResolvedValue([{ file_path: "file1.ts" }]);
      await model.handleOpenConflictResolver();
      expect(model.mergeConflicts).toHaveLength(1);
      expect(model.showConflictResolver).toBe(true);
      expect(model.mergeRecoveryLoading).toBe(false);
    });
    it("handleOpenConflictResolver shows info when no conflicts", async () => {
      vi.mocked(gitService.getMergeConflicts).mockResolvedValue([]);
      await model.handleOpenConflictResolver();
      expect(addToast).toHaveBeenCalledWith(expect.stringContaining("No unresolved"), "info");
    });
    it("handleOpenConflictResolver sets gitError on failure", async () => {
      vi.mocked(gitService.getMergeConflicts).mockRejectedValue(new Error("Fail"));
      await model.handleOpenConflictResolver();
      expect(model.gitError).not.toBeNull();
    });
    it("handleResolveConflicts processes event detail", async () => {
      vi.mocked(gitService.resolveMergeConflicts).mockResolvedValue({});
      vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: false });
      vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue("");
      await model.handleResolveConflicts(new CustomEvent("resolve", { detail: { "file1.ts": "ours" } }));
      expect(gitService.resolveMergeConflicts).toHaveBeenCalled();
      expect(model.mergeResolveInProgress).toBe(false);
    });
    it("handleResolveConflicts warns on empty detail", async () => {
      await model.handleResolveConflicts(new CustomEvent("resolve", { detail: {} }));
      expect(addToast).toHaveBeenCalledWith(expect.stringContaining("resolutions"), "warning");
      expect(gitService.resolveMergeConflicts).not.toHaveBeenCalled();
    });
    it("handleResolveConflicts sets gitError on failure", async () => {
      vi.mocked(gitService.resolveMergeConflicts).mockRejectedValue(new Error("Resolve fail"));
      await model.handleResolveConflicts(new CustomEvent("resolve", { detail: { "x": "y" } }));
      expect(model.gitError).not.toBeNull();
    });
    it("handleAbortUnfinishedMerge calls abort and resets", async () => {
      vi.mocked(gitService.abortMerge).mockResolvedValue({});
      vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue("");
      await model.handleAbortUnfinishedMerge();
      expect(gitService.abortMerge).toHaveBeenCalledWith("test-dashboard", "env-123");
      expect(model.mergeAbortInProgress).toBe(false);
    });
    it("handleAbortUnfinishedMerge sets gitError on failure", async () => {
      vi.mocked(gitService.abortMerge).mockRejectedValue(new Error("Abort fail"));
      await model.handleAbortUnfinishedMerge();
      expect(model.gitError).not.toBeNull();
    });
    it("handleContinueUnfinishedMerge calls continue", async () => {
      vi.mocked(gitService.continueMerge).mockResolvedValue({});
      vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue("");
      await model.handleContinueUnfinishedMerge();
      expect(gitService.continueMerge).toHaveBeenCalledWith("test-dashboard", "", "env-123");
      expect(model.mergeContinueInProgress).toBe(false);
    });
    it("handleContinueUnfinishedMerge loads recovery on failure", async () => {
      vi.mocked(gitService.continueMerge).mockRejectedValue(new Error("Continue fail"));
      vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true });
      await model.handleContinueUnfinishedMerge();
      expect(model.mergeContinueInProgress).toBe(false);
      // _setGitError sets gitError before loadMergeRecoveryState clears it
      // but we verify the loading flag is reset
    });
    it("handleCopyUnfinishedMergeCommands copies to clipboard", async () => {
      Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
      const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
      model.unfinishedMergeContext = { commands: ["git merge --abort"] } as any;
      await model.handleCopyUnfinishedMergeCommands();
      expect(writeTextSpy).toHaveBeenCalledWith("git merge --abort");
      expect(model.copyingUnfinishedMergeCommands).toBe(false);
      writeTextSpy.mockRestore();
    });
    it("handleCopyUnfinishedMergeCommands warns when no commands", async () => {
      model.unfinishedMergeContext = { commands: [] } as any;
      await model.handleCopyUnfinishedMergeCommands();
      expect(addToast).toHaveBeenCalledWith(expect.stringContaining("копирования"), "warning");
    });
    it("handleCopyUnfinishedMergeCommands handles clipboard error", async () => {
      Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard blocked")) } });
      model.unfinishedMergeContext = { commands: ["git status"] } as any;
      await model.handleCopyUnfinishedMergeCommands();
      expect(addToast).toHaveBeenCalledWith(expect.stringContaining('скопировать'), "error");
      expect(model.copyingUnfinishedMergeCommands).toBe(false);
    });
  });

  describe("openUnfinishedMergeDialogFromError", () => {
    it("returns true when context found", () => {
      const err = new Error("Unfinished merge");
      (err as any).status = 409;
      (err as any).detail = { error_code: "GIT_UNFINISHED_MERGE", repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", manual_commands: ["git merge --abort"], next_steps: ["Abort"] };
      const result = model.openUnfinishedMergeDialogFromError(err);
      expect(result).toBe(true);
      expect(model.showUnfinishedMergeDialog).toBe(true);
      expect(model.unfinishedMergeContext).not.toBeNull();
    });
  });

  describe("coverage — remaining uncovered paths", () => {
    it("buildGitErrorFromException handles falsy error", () => {
      (model as any)._setGitError(null);
      expect(model.gitError).not.toBeNull();
      expect(model.gitError!.message).toBe('Неизвестная ошибка Git');
    });

    it("_snapshot returns helper object and calls resolveEnvId", () => {
      const snap = model._snapshot();
      const envId = snap.resolveEnvId();
      expect(typeof envId).toBe("string");
      expect(typeof snap.normalizeEnvStageFn).toBe("function");
    });

    it("initialize handles getRepositoryBinding failure", async () => {
      vi.mocked(gitService.getConfigs).mockResolvedValue([{ id: "cfg-1" }]);
      vi.mocked(gitService.getBranches).mockResolvedValue(["main"]);
      vi.mocked(gitService.getStatus).mockResolvedValue(makeWs());
      vi.mocked(gitService.getDiff).mockResolvedValue("");
      vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123" }]);
      vi.mocked(gitService.getRepositoryBinding).mockRejectedValue(new Error("Binding fail"));
      await model.initialize();
      expect(model.repositoryProvider).toBe("");
      expect(model.repositoryBindingRemoteUrl).toBe("");
    });

    it("handlePromote sets error on API failure", async () => {
      model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr";
      vi.mocked(gitService.promote).mockRejectedValue(new Error("Promote fail"));
      await model.handlePromote();
      expect(model.gitError).not.toBeNull();
    });

    it("handleInit sets error on init failure", async () => {
      model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git";
      vi.mocked(gitService.initRepository).mockRejectedValue(new Error("Init fail"));
      await model.handleInit();
      expect(model.gitError).not.toBeNull();
    });

    it("handleCreateRemoteRepo succeeds with clone_url", async () => {
      model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
      model.dashboardTitle = "Test Dash";
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
      vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" });
      await model.handleCreateRemoteRepo();
      expect(model.remoteUrl).toBe("https://github.com/org/repo.git");
      promptSpy.mockRestore();
    });

    it("handleCreateRemoteRepo succeeds with html_url", async () => {
      model.configs = [{ id: "cfg-1", provider: "gitlab" }]; model.selectedConfigId = "cfg-1";
      model.dashboardTitle = "Test Dash";
      const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
      vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" });
      await model.handleCreateRemoteRepo();
      expect(model.remoteUrl).toBe("https://gitlab.com/org/repo");
      promptSpy.mockRestore();
    });
  });
});
// #endregion GitManagerModelTests
