import child_process, { ChildProcess, ExecException } from "child_process";
import turboIgnore from "../src/ignore";
import {
  spyConsole,
  spyExit,
  SpyExit,
  mockEnv,
  validateLogs,
} from "turbo-test-utils";

function expectBuild(mockExit: SpyExit) {
  expect(mockExit.exit).toHaveBeenCalledWith(1);
}

function expectIgnore(mockExit: SpyExit) {
  expect(mockExit.exit).toHaveBeenCalledWith(0);
}

describe("turboIgnore()", () => {
  mockEnv();
  const mockExit = spyExit();
  const mockConsole = spyConsole();

  it("throws error and allows build when exec fails", async () => {
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            "error" as unknown as ExecException,
            "stdout",
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: { workspace: "test-workspace" },
    });

    expect(mockExec).toHaveBeenCalledWith(
      "npx turbo run build --filter=test-workspace...[HEAD^] --dry=json",
      expect.anything(),
      expect.anything()
    );

    validateLogs(["UNKNOWN_ERROR: error"], mockConsole.error, {
      prefix: "≫  ",
    });

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("throws pretty error and allows build when exec fails", async () => {
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            {
              message:
                "run failed: We did not detect an in-use package manager for your project",
            } as unknown as ExecException,
            "stdout",
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: { workspace: "test-workspace" },
    });

    expect(mockExec).toHaveBeenCalledWith(
      "npx turbo run build --filter=test-workspace...[HEAD^] --dry=json",
      expect.anything(),
      expect.anything()
    );

    validateLogs(
      [
        `turbo-ignore could not complete - no package manager detected, please commit a lockfile, or set "packageManager" in your root "package.json"`,
      ],
      mockConsole.warn,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("throws pretty error and allows build when can't find previous sha", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "too-far-back";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            {
              message:
                " ERROR  run failed: failed to resolve packages to run: commit too-far-back does not exist",
            } as unknown as ExecException,
            "stdout",
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: { workspace: "test-workspace" },
    });

    expect(mockExec).toHaveBeenCalledWith(
      "npx turbo run build --filter=test-workspace...[too-far-back] --dry=json",
      expect.anything(),
      expect.anything()
    );

    validateLogs(
      [
        `turbo-ignore could not complete - commit does not exist or is unreachable`,
      ],
      mockConsole.warn,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("throws pretty error and allows build when fallback fails", async () => {
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            {
              message:
                "ERROR run failed: failed to resolve packages to run: commit HEAD^ does not exist",
            } as unknown as ExecException,
            "stdout",
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: { workspace: "test-workspace", fallback: "HEAD^" },
    });

    expect(mockExec).toHaveBeenCalledWith(
      "npx turbo run build --filter=test-workspace...[HEAD^] --dry=json",
      expect.anything(),
      expect.anything()
    );

    validateLogs(
      [
        `turbo-ignore could not complete - parent commit does not exist or is unreachable`,
      ],
      mockConsole.warn,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("skips checks and allows build when no workspace can be found", async () => {
    turboIgnore({
      args: {
        directory: "__fixtures__/no-app",
      },
    });
    validateLogs(
      [
        () => [
          "≫  ",
          expect.stringContaining(
            " could not be found. turbo-ignore inferencing failed"
          ),
        ],
      ],
      mockConsole.error,
      { prefix: "≫  " }
    );
    expectBuild(mockExit);
  });

  it("skips checks and allows build when a workspace with no name is found", async () => {
    turboIgnore({
      args: {
        directory: "__fixtures__/invalid-app",
      },
    });
    validateLogs(
      [
        () => [
          "≫  ",
          expect.stringContaining(' is missing the "name" field (required).'),
        ],
      ],
      mockConsole.error,
      { prefix: "≫  " }
    );
    expectBuild(mockExit);
  });

  it("skips checks and allows build when no monorepo root can be found", async () => {
    turboIgnore({
      args: { directory: "/" },
    });
    expectBuild(mockExit);
    expect(mockConsole.error).toHaveBeenLastCalledWith(
      "≫  ",
      "monorepo root not found. turbo-ignore inferencing failed"
    );
  });

  it("skips checks and allows build when TURBO_FORCE is set", async () => {
    process.env.TURBO_FORCE = "true";
    turboIgnore({
      args: { workspace: "test-workspace" },
    });
    expect(mockConsole.log).toHaveBeenNthCalledWith(
      2,
      "≫  ",
      "`TURBO_FORCE` detected"
    );
    expectBuild(mockExit);
  });

  it("allows build when no comparison is returned", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
    turboIgnore({
      args: {
        workspace: "test-app",
        directory: "__fixtures__/app",
      },
    });
    expect(mockConsole.log).toHaveBeenNthCalledWith(
      4,
      "≫  ",
      'no previous deployments found for "test-app" on branch "my-branch".'
    );
    expectBuild(mockExit);
  });

  it("skips build for `previousDeploy` comparison with no changes", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            null,
            '{"packages":[],"tasks":[]}',
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });
    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });
    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "build" as the task as it was unspecified',
        `found previous deployment ("last-deployed-sha") for \"test-app\" on branch \"my-branch\"`,
        "analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
        "this project and its dependencies are not affected",
        () => expect.stringContaining("⬜️  ignoring the change"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectIgnore(mockExit);
    mockExec.mockRestore();
  });

  it("allows build for `previousDeploy` comparison with changes", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            null,
            '{"packages":["test-app"],"tasks":[]}',
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });
    turboIgnore({
      args: {
        task: "workspace#build",
        directory: "__fixtures__/app",
      },
    });
    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "workspace#build" as the task from the arguments',
        'found previous deployment ("last-deployed-sha") for "test-app" on branch "my-branch"',
        'analyzing results of `npx turbo run "workspace#build" --filter=test-app...[last-deployed-sha] --dry=json`',
        'this commit affects "test-app"',
        () => expect.stringContaining("✅  proceeding with deployment"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("allows build for `previousDeploy` comparison with single dependency change", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            null,
            '{"packages":["test-app", "ui"],"tasks":[]}',
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });
    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });
    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "build" as the task as it was unspecified',
        'found previous deployment ("last-deployed-sha") for "test-app" on branch "my-branch"',
        "analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
        'this commit affects "test-app" and 1 dependency (ui)',
        () => expect.stringContaining("✅  proceeding with deployment"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("allows build for `previousDeploy` comparison with multiple dependency changes", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            null,
            '{"packages":["test-app", "ui", "tsconfig"],"tasks":[]}',
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });
    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });
    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "build" as the task as it was unspecified',
        'found previous deployment ("last-deployed-sha") for "test-app" on branch "my-branch"',
        "analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
        'this commit affects "test-app" and 2 dependencies (ui, tsconfig)',
        () => expect.stringContaining("✅  proceeding with deployment"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("throws error and allows build when json cannot be parsed", async () => {
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(null, "stdout", "stderr") as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });

    expect(mockExec).toHaveBeenCalledWith(
      "npx turbo run build --filter=test-app...[HEAD^] --dry=json",
      expect.anything(),
      expect.anything()
    );
    validateLogs(
      [
        "failed to parse JSON output from `npx turbo run build --filter=test-app...[HEAD^] --dry=json`.",
      ],
      mockConsole.error,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("throws error and allows build when stdout is null", async () => {
    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            null,
            null as unknown as string,
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });

    expect(mockExec).toHaveBeenCalledWith(
      "npx turbo run build --filter=test-app...[HEAD^] --dry=json",
      expect.anything(),
      expect.anything()
    );
    validateLogs(
      [
        "failed to parse JSON output from `npx turbo run build --filter=test-app...[HEAD^] --dry=json`.",
      ],
      mockConsole.error,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
    mockExec.mockRestore();
  });

  it("skips when commit message contains a skip string", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_COMMIT_MESSAGE = "[vercel skip]";

    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });

    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "build" as the task as it was unspecified',
        "found commit message: [vercel skip]",
        () => expect.stringContaining("⬜️  ignoring the change"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectIgnore(mockExit);
  });

  it("deploys when commit message contains a deploy string", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_COMMIT_MESSAGE = "[vercel deploy]";

    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });

    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "build" as the task as it was unspecified',
        "found commit message: [vercel deploy]",
        () => expect.stringContaining("✅  proceeding with deployment"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectBuild(mockExit);
  });

  it("runs full turbo-ignore check when commit message contains a conflicting string", async () => {
    process.env.VERCEL = "1";
    process.env.VERCEL_GIT_COMMIT_MESSAGE = "[vercel deploy] [vercel skip]";
    process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
    process.env.VERCEL_GIT_COMMIT_REF = "my-branch";

    const mockExec = jest
      .spyOn(child_process, "exec")
      .mockImplementation((command, options, callback) => {
        if (callback) {
          return callback(
            null,
            '{"packages":[],"tasks":[]}',
            "stderr"
          ) as unknown as ChildProcess;
        }
        return {} as unknown as ChildProcess;
      });

    turboIgnore({
      args: {
        directory: "__fixtures__/app",
      },
    });

    validateLogs(
      [
        "Using Turborepo to determine if this project is affected by the commit...\n",
        'inferred "test-app" as workspace from "package.json"',
        'using "build" as the task as it was unspecified',
        "conflicting commit messages found: [vercel deploy] and [vercel skip]",
        `found previous deployment ("last-deployed-sha") for \"test-app\" on branch \"my-branch\"`,
        "analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
        "this project and its dependencies are not affected",
        () => expect.stringContaining("⬜️  ignoring the change"),
      ],
      mockConsole.log,
      { prefix: "≫  " }
    );

    expectIgnore(mockExit);
    mockExec.mockRestore();
  });
});
