// 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]
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(),
}));

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";

// ── Types ─────────────────────────────────────────────────────────

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

// ── Helpers ──────────────────────────────────────────────────────

/** Create a model with default constructor values. */
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,
  });
}

/** Minimal workspace status payload. */
function makeWorkspaceStatus(overrides: Partial<WorkspaceStatusFixture> = {}): WorkspaceStatusFixture {
  return {
    is_dirty: false,
    staged_files: [],
    modified_files: [],
    untracked_files: [],
    current_branch: "main",
    ...overrides,
  };
}

// ── Suite ────────────────────────────────────────────────────────

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

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

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Derived computations
  // ═══════════════════════════════════════════════════════════════
  describe("derived state", () => {
    it("hasWorkspaceChanges returns false when workspaceStatus is null", () => {
      model.workspaceStatus = null;
      expect(model.hasWorkspaceChanges).toBe(false);
    });

    it("hasWorkspaceChanges returns true when there are staged files", () => {
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["file1.py"] });
      expect(model.hasWorkspaceChanges).toBe(true);
    });

    it("hasWorkspaceChanges returns true when there are modified files", () => {
      model.workspaceStatus = makeWorkspaceStatus({ modified_files: ["file2.py"] });
      expect(model.hasWorkspaceChanges).toBe(true);
    });

    it("hasWorkspaceChanges returns true when there are untracked files", () => {
      model.workspaceStatus = makeWorkspaceStatus({ untracked_files: ["file3.py"] });
      expect(model.hasWorkspaceChanges).toBe(true);
    });

    it("hasWorkspaceChanges returns false when all file lists are empty", () => {
      model.workspaceStatus = makeWorkspaceStatus({
        staged_files: [],
        modified_files: [],
        untracked_files: [],
      });
      expect(model.hasWorkspaceChanges).toBe(false);
    });

    it("changedFilesCount aggregates all categories", () => {
      model.workspaceStatus = makeWorkspaceStatus({
        staged_files: ["a.py", "b.py"],
        modified_files: ["c.py"],
        untracked_files: ["d.py", "e.py"],
      });
      expect(model.changedFilesCount).toBe(5);
    });

    it("changedFilesCount returns 0 when workspaceStatus is null", () => {
      model.workspaceStatus = null;
      expect(model.changedFilesCount).toBe(0);
    });

    it("changedFilesCount returns 0 when all file lists are empty", () => {
      model.workspaceStatus = makeWorkspaceStatus();
      expect(model.changedFilesCount).toBe(0);
    });

    it("resolvedEnvId falls back to envId when provided", () => {
      model.envId = "env-456";
      expect(model.resolvedEnvId).toBe("env-456");
    });

    it("originHost and configHost are empty strings when URLs are empty", () => {
      model.repositoryBindingRemoteUrl = "";
      model.repositoryConfigUrl = "";
      expect(model.originHost).toBe("");
      expect(model.configHost).toBe("");
      // Both empty → falsy (empty string, not exactly false but falsy in conditionals)
      expect(model.hasOriginConfigMismatch).toBeFalsy();
    });

    it("hasOriginConfigMismatch is false when both hosts are same", () => {
      model.repositoryBindingRemoteUrl = "https://git.example.com/org/repo.git";
      model.repositoryConfigUrl = "https://git.example.com/config";
      expect(model.hasOriginConfigMismatch).toBe(false);
    });

    it("pushProviderLabel returns default label when no configs", () => {
      model.configs = [];
      model.selectedConfigId = "";
      model.repositoryProvider = "";
      expect(model.pushProviderLabel).toBe("git");
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Loading flag mutual exclusion (defaults)
  // ═══════════════════════════════════════════════════════════════
  describe("mutual exclusion — loading flags", () => {
    it("all loading flags default to false except checkingStatus", () => {
      // checkingStatus starts true per original GitManager.svelte
      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.mergeRecoveryLoading).toBe(false);
      expect(model.creatingRemoteRepo).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: error handling
  // ═══════════════════════════════════════════════════════════════
  describe("error handling", () => {
    it("clearGitError resets both error fields", () => {
      model.gitError = { message: "Something broke", status: 500, error_code: null, files: [], next_steps: [], detail: null };
      model.gitErrorType = "error";
      model.clearGitError();
      expect(model.gitError).toBeNull();
      expect(model.gitErrorType).toBe("error");
    });

    it("handleCommit sets gitError on API failure", async () => {
      model.commitMessage = "fix: test";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      vi.mocked(gitService.commit).mockRejectedValueOnce(new Error("Commit failed"));

      await model.handleCommit();

      expect(model.gitError).not.toBeNull();
      expect(model.gitError!.message).toContain("Commit failed");
      expect(model.committing).toBe(false);
    });

    it("handleCommit clears gitError before operation", async () => {
      model.commitMessage = "fix: test";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      model.gitError = { message: "Previous error", status: 500, error_code: null, files: [], next_steps: [], detail: null };
      vi.mocked(gitService.commit).mockRejectedValueOnce(new Error("New error"));

      await model.handleCommit();

      // error was cleared (set to structured error), not the old one
      expect(model.gitError!.message).not.toBe("Previous error");
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Action loading state lifecycle
  // ═══════════════════════════════════════════════════════════════
  describe("loading state lifecycle", () => {
    it("checkStatus transitions checkingStatus true→false on success", async () => {
      vi.mocked(gitService.getBranches).mockResolvedValueOnce(["main"]);
      vi.mocked(gitService.getStatus).mockResolvedValueOnce(makeWorkspaceStatus());
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");

      // Initially true (default)
      expect(model.checkingStatus).toBe(true);
      await model.checkStatus();
      // Should be false after completion
      expect(model.checkingStatus).toBe(false);
      expect(model.initialized).toBe(true);
    });

    it("checkStatus sets initialized=false on API failure", async () => {
      vi.mocked(gitService.getBranches).mockRejectedValueOnce(new Error("No repo"));
      expect(model.checkingStatus).toBe(true);

      await model.checkStatus();

      expect(model.initialized).toBe(false);
      expect(model.checkingStatus).toBe(false);
    });

    it("handleCommit sets committing true then false", async () => {
      model.commitMessage = "fix: test";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      vi.mocked(gitService.commit).mockImplementation(() => new Promise((r) => setTimeout(r, 10)));

      const promise = model.handleCommit();
      expect(model.committing).toBe(true);
      await promise;
      expect(model.committing).toBe(false);
    });

    it("handleCommit resets committing on failure", async () => {
      model.commitMessage = "fix: test";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      vi.mocked(gitService.commit).mockRejectedValueOnce(new Error("Fail"));

      await model.handleCommit();

      expect(model.committing).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Guard conditions
  // ═══════════════════════════════════════════════════════════════
  describe("guard conditions", () => {
    it("handleCommit returns early when commitMessage is empty", async () => {
      model.commitMessage = "";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });

      await model.handleCommit();

      expect(gitService.commit).not.toHaveBeenCalled();
    });

    it("handleCommit returns early when hasWorkspaceChanges is false", async () => {
      model.commitMessage = "fix: test";
      model.workspaceStatus = makeWorkspaceStatus();

      await model.handleCommit();

      expect(gitService.commit).not.toHaveBeenCalled();
    });

    it("handlePromote validates branches are different", async () => {
      model.promoteFromBranch = "main";
      model.promoteToBranch = "main";

      await model.handlePromote();

      expect(gitService.promote).not.toHaveBeenCalled();
      expect(addToast).toHaveBeenCalledWith(
        expect.stringContaining("разные"),
        "error",
      );
    });

    it("handlePromote validates reason in direct mode", async () => {
      model.promoteFromBranch = "dev";
      model.promoteToBranch = "main";
      model.promoteMode = "direct";
      model.promoteReason = "";

      await model.handlePromote();

      expect(gitService.promote).not.toHaveBeenCalled();
      expect(addToast).toHaveBeenCalledWith(
        expect.stringContaining("причину"),
        "error",
      );
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Commit calls gitService.commit
  // ═══════════════════════════════════════════════════════════════
  describe("handleCommit — API calls", () => {
    it("calls gitService.commit with correct args", async () => {
      model.commitMessage = "fix: resolve mapping issue";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      model.autoPushAfterCommit = false;
      vi.mocked(gitService.commit).mockResolvedValueOnce({});

      await model.handleCommit();

      expect(gitService.commit).toHaveBeenCalledWith(
        "test-dashboard",
        "fix: resolve mapping issue",
        [],
        "env-123",
      );
    });

    it("calls push when autoPushAfterCommit is true", async () => {
      model.commitMessage = "fix: auto-push";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      model.autoPushAfterCommit = true;
      vi.mocked(gitService.commit).mockResolvedValueOnce({});
      vi.mocked(gitService.push).mockResolvedValueOnce({});

      await model.handleCommit();

      expect(gitService.push).toHaveBeenCalledWith("test-dashboard", "env-123");
    });

    it("does not call push when autoPushAfterCommit is false", async () => {
      model.commitMessage = "fix: no-push";
      model.workspaceStatus = makeWorkspaceStatus({ staged_files: ["x.py"] });
      model.autoPushAfterCommit = false;
      vi.mocked(gitService.commit).mockResolvedValueOnce({});

      await model.handleCommit();

      expect(gitService.push).not.toHaveBeenCalled();
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Promote calls gitService.promote
  // ═══════════════════════════════════════════════════════════════
  describe("handlePromote — API calls", () => {
    it("calls gitService.promote with correct args in MR mode", async () => {
      model.promoteFromBranch = "dev";
      model.promoteToBranch = "main";
      model.promoteMode = "mr";
      model.promoteReason = "";
      vi.mocked(gitService.promote).mockResolvedValueOnce({ url: "https://git.example.com/mr/1" });

      await model.handlePromote();

      expect(gitService.promote).toHaveBeenCalledWith(
        "test-dashboard",
        {
          from_branch: "dev",
          to_branch: "main",
          mode: "mr",
          title: expect.stringContaining("Promote"),
          description: undefined,
          reason: undefined,
        },
        "env-123",
      );
    });

    it("calls gitService.promote with reason in direct mode", async () => {
      model.promoteFromBranch = "dev";
      model.promoteToBranch = "preprod";
      model.promoteMode = "direct";
      model.promoteReason = "Hotfix required";
      vi.mocked(gitService.promote).mockResolvedValueOnce({});

      await model.handlePromote();

      expect(gitService.promote).toHaveBeenCalledWith(
        "test-dashboard",
        expect.objectContaining({
          from_branch: "dev",
          to_branch: "preprod",
          mode: "direct",
          reason: "Hotfix required",
          description: expect.stringContaining("Hotfix required"),
        }),
        "env-123",
      );
      expect(model.promoting).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Pull calls gitService.pull
  // ═══════════════════════════════════════════════════════════════
  describe("handlePull — API calls", () => {
    it("calls gitService.pull with correct args", async () => {
      vi.mocked(gitService.pull).mockResolvedValueOnce({});
      vi.mocked(gitService.getStatus).mockResolvedValueOnce(makeWorkspaceStatus());
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");

      await model.handlePull();

      expect(gitService.pull).toHaveBeenCalledWith("test-dashboard", "env-123");
      expect(model.isPulling).toBe(false);
    });

    it("opens unfinished merge dialog on 409 error", async () => {
      const mergeError = new Error("Unfinished merge");
      (mergeError as Record<string, unknown>).status = 409;
      (mergeError as Record<string, unknown>).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 or resolve"],
      };

      vi.mocked(gitService.pull).mockRejectedValueOnce(mergeError);

      // getMergeStatus needs to return unfinished merge data for loadMergeRecoveryState
      vi.mocked(gitService.getMergeStatus).mockResolvedValueOnce({
        has_unfinished_merge: true,
        repository_path: "/repo",
        git_dir: "/repo/.git",
        current_branch: "main",
        merge_head: "feature",
      });

      await model.handlePull();

      expect(model.showUnfinishedMergeDialog).toBe(true);
      expect(model.unfinishedMergeContext).not.toBeNull();
      // should still call pull
      expect(gitService.pull).toHaveBeenCalledWith("test-dashboard", "env-123");
    });

    it("sets gitError on pull failure (non-merge)", async () => {
      vi.mocked(gitService.pull).mockRejectedValueOnce(new Error("Network error"));

      await model.handlePull();

      expect(model.gitError).not.toBeNull();
      expect(model.isPulling).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Push calls gitService.push
  // ═══════════════════════════════════════════════════════════════
  describe("handlePush — API calls", () => {
    it("calls gitService.push with correct args", async () => {
      vi.mocked(gitService.push).mockResolvedValueOnce({});
      vi.mocked(gitService.getStatus).mockResolvedValueOnce(makeWorkspaceStatus());
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");

      await model.handlePush();

      expect(gitService.push).toHaveBeenCalledWith("test-dashboard", "env-123");
      expect(model.isPushing).toBe(false);
    });

    it("sets gitError on push failure", async () => {
      vi.mocked(gitService.push).mockRejectedValueOnce(new Error("Push rejected"));

      await model.handlePush();

      expect(model.gitError).not.toBeNull();
      expect(model.isPushing).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Sync calls gitService.sync
  // ═══════════════════════════════════════════════════════════════
  describe("handleSync — API calls", () => {
    it("calls gitService.sync with correct args", async () => {
      vi.mocked(gitService.sync).mockResolvedValueOnce({});
      vi.mocked(gitService.getStatus).mockResolvedValueOnce(makeWorkspaceStatus());
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");
      vi.mocked(gitService.getDiff).mockResolvedValueOnce("");

      await model.handleSync();

      expect(gitService.sync).toHaveBeenCalledWith(
        "test-dashboard",
        "env-123",
        "env-123",
      );
      expect(model.loading).toBe(false);
    });

    it("sets gitError on sync failure", async () => {
      vi.mocked(gitService.sync).mockRejectedValueOnce(new Error("Sync failed"));

      await model.handleSync();

      expect(model.gitError).not.toBeNull();
      expect(model.loading).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════
  // @INVARIANT: Generate message calls api.postApi
  // ═══════════════════════════════════════════════════════════════
  describe("handleGenerateMessage — API calls", () => {
    it("calls api.postApi and sets commitMessage", async () => {
      vi.mocked(api.postApi).mockResolvedValueOnce({ message: "AI generated message" });

      await model.handleGenerateMessage();

      expect(api.postApi).toHaveBeenCalledWith(
        expect.stringContaining("/git/repositories/test-dashboard/generate-message"),
        undefined,
        { suppressToast: true },
      );
      expect(model.commitMessage).toBe("AI generated message");
      expect(model.generatingMessage).toBe(false);
    });
  });
});
// #endregion GitManagerModelTests
