require("plenary.async").tests.add_to_env()
local conform = require("conform")
local fs = require("conform.fs")
local runner = require("conform.runner")
local test_util = require("tests.test_util")
local util = require("conform.util")

describe("runner", function()
  local OUTPUT_FILE
  local CLEANUP_FILES = {}

  ---@param lines string[]
  local function set_formatter_output(lines)
    local fd, output_file = vim.loop.fs_mkstemp(".testenv/outputXXXXXXXXX")
    assert(type(fd) == "number" and output_file, fd)
    local content = table.concat(lines, "\n")
    vim.loop.fs_write(fd, content)
    -- Make sure we add the final newline
    vim.loop.fs_write(fd, "\n")
    vim.loop.fs_fsync(fd)
    vim.loop.fs_close(fd)
    OUTPUT_FILE = output_file
    table.insert(CLEANUP_FILES, output_file)
  end

  after_each(function()
    test_util.reset_editor()
    OUTPUT_FILE = nil
    for _, file in ipairs(CLEANUP_FILES) do
      if vim.loop.fs_stat(file) then
        vim.loop.fs_unlink(file)
      end
    end
    CLEANUP_FILES = {}
  end)

  it("resolves config function", function()
    conform.formatters.test = function()
      return {
        meta = { url = "", description = "" },
        command = "echo",
      }
    end
    local config = assert(conform.get_formatter_config("test"))
    assert.are.same({
      meta = { url = "", description = "" },
      command = "echo",
      stdin = true,
    }, config)
  end)

  describe("build_context", function()
    it("sets the filename and dirname", function()
      vim.cmd.edit({ args = { "README.md" } })
      local bufnr = vim.api.nvim_get_current_buf()
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      local filename = vim.api.nvim_buf_get_name(bufnr)
      assert.equal(bufnr, ctx.buf)
      assert.equal(filename, ctx.filename)
      assert.equal(vim.fs.dirname(filename), ctx.dirname)
    end)

    it("sets the shiftwidth to shiftwidth", function()
      vim.cmd.edit({ args = { "README.md" } })
      local bufnr = vim.api.nvim_get_current_buf()
      vim.bo[bufnr].shiftwidth = 7
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      assert.equal(7, ctx.shiftwidth)
    end)

    it("sets the shiftwidth to tabstop as fallback", function()
      vim.cmd.edit({ args = { "README.md" } })
      local bufnr = vim.api.nvim_get_current_buf()
      vim.bo[bufnr].shiftwidth = 0
      vim.bo[bufnr].tabstop = 3
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      assert.equal(3, ctx.shiftwidth)
    end)

    it("sets temp file when stdin = false", function()
      vim.cmd.edit({ args = { "README.md" } })
      local bufnr = vim.api.nvim_get_current_buf()
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
        stdin = false,
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      local bufname = vim.api.nvim_buf_get_name(bufnr)
      local dirname = vim.fs.dirname(bufname)
      assert.equal(bufnr, ctx.buf)
      assert.equal(dirname, ctx.dirname)
      assert.truthy(ctx.filename:match(dirname .. "/.conform.%d+.README.md$"))
    end)
  end)

  describe("build_cmd", function()
    it("replaces $FILENAME in args", function()
      vim.cmd.edit({ args = { "README.md" } })
      local bufnr = vim.api.nvim_get_current_buf()
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
        args = { "$FILENAME" },
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      local cmd = runner.build_cmd("", ctx, config)
      assert.are.same({ vim.fn.exepath("echo"), vim.api.nvim_buf_get_name(bufnr) }, cmd)
    end)

    it("replaces $DIRNAME in args", function()
      vim.cmd.edit({ args = { "README.md" } })
      local bufnr = vim.api.nvim_get_current_buf()
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
        args = { "$DIRNAME" },
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      local cmd = runner.build_cmd("", ctx, config)
      assert.are.same(
        { vim.fn.exepath("echo"), vim.fs.dirname(vim.api.nvim_buf_get_name(bufnr)) },
        cmd
      )
    end)

    it("resolves arg function", function()
      vim.cmd.edit({ args = { "README.md" } })
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
        args = function()
          return { "--stdin" }
        end,
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      local cmd = runner.build_cmd("", ctx, config)
      assert.are.same({ vim.fn.exepath("echo"), "--stdin" }, cmd)
    end)

    it("resolves arg function with string results", function()
      vim.cmd.edit({ args = { "README.md" } })
      conform.formatters.test = {
        meta = { url = "", description = "" },
        command = "echo",
        args = function()
          return "| patch"
        end,
      }
      local config = assert(conform.get_formatter_config("test"))
      local ctx = runner.build_context(0, config)
      local cmd = runner.build_cmd("", ctx, config)
      assert.are.same(util.shell_build_argv(vim.fn.exepath("echo") .. " | patch"), cmd)
    end)
  end)

  describe("e2e", function()
    before_each(function()
      conform.formatters.test = {
        command = "tests/fake_formatter.sh",
        args = function()
          if OUTPUT_FILE then
            return { OUTPUT_FILE }
          end
          return {}
        end,
      }
    end)

    ---@param buf_content string
    ---@param expected string
    ---@param opts? table
    local function run_formatter(buf_content, expected, opts)
      local bufnr = vim.fn.bufadd("testfile")
      vim.fn.bufload(bufnr)
      vim.api.nvim_set_current_buf(bufnr)
      local lines = vim.split(buf_content, "\n", { plain = true })
      vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
      vim.bo[bufnr].modified = false
      local expected_lines = vim.split(expected, "\n", { plain = true })
      set_formatter_output(expected_lines)
      conform.format(vim.tbl_extend("keep", opts or {}, { formatters = { "test" }, quiet = true }))
      return expected_lines
    end

    ---@param buf_content string
    ---@param new_content string
    local function run_formatter_test(buf_content, new_content)
      local lines = run_formatter(buf_content, new_content)
      assert.are.same(lines, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end

    it("sets the correct output", function()
      run_formatter_test(
        [[
        if true {
        print("hello")
      }]],
        [[
      if true {
        print("hello")
      }]]
      )
      run_formatter_test(
        [[
      if true {
        print("hello")
      }]],
        [[
      if true {
        print("goodbye")
      }]]
      )
      run_formatter_test(
        [[
      if true {
        print("hello")
      }]],
        [[
      if true {
        print("hello world")
        print("hello world")
        print("hello world")
      }]]
      )
      run_formatter_test(
        [[
print("a")
print("b")
print("c")
      ]],
        [[
print("c")
print("b")
print("a")
      ]]
      )
      run_formatter_test("hello\ngoodbye", "hello\n\n\ngoodbye")
      run_formatter_test("hello", "hello\ngoodbye")
      run_formatter_test("hello\ngoodbye", "hello")
      run_formatter_test("", "hello")
      run_formatter_test("\nfoo", "\nhello\nfoo")
      run_formatter_test("hello", "hello\n")
      run_formatter_test("hello", "hello\n\n")
      run_formatter_test("hello\n", "hello")
      run_formatter_test("hello\n ", "hello")

      -- These should generate no changes to the buffer
      run_formatter_test("hello\n", "hello\n")
      assert.falsy(vim.bo.modified)
      run_formatter_test("hello", "hello")
      assert.falsy(vim.bo.modified)
    end)

    it("does not change output if formatter fails", function()
      conform.formatters.test.args = util.extend_args(conform.formatters.test.args, { "--fail" })
      run_formatter("hello", "goodbye")
      assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end)

    it("allows nonzero exit codes", function()
      conform.formatters.test.args = util.extend_args(conform.formatters.test.args, { "--fail" })
      conform.formatters.test.exit_codes = { 0, 1 }
      run_formatter_test("hello", "goodbye")
    end)

    it("does not format if it times out", function()
      conform.formatters.test.args = util.extend_args(conform.formatters.test.args, { "--timeout" })
      run_formatter("hello", "goodbye", { timeout_ms = 10 })
      assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end)

    it("can format async", function()
      run_formatter("hello", "goodbye", { async = true })
      assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
      vim.wait(1000, function()
        return vim.api.nvim_buf_get_lines(0, 0, -1, false)[1] == "goodbye"
      end)
      assert.are.same({ "goodbye" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end)

    it("discards formatting changes if buffer has been concurrently modified", function()
      run_formatter("hello", "goodbye", { async = true })
      assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
      vim.api.nvim_buf_set_lines(0, 0, -1, true, { "newcontent" })
      vim.wait(1000, function()
        return vim.api.nvim_buf_get_lines(0, 0, -1, false)[1] == "newcontent"
      end)
      assert.are.same({ "newcontent" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end)

    it("discards formatting changes if formatter output is empty /w non-empty input", function()
      local bufnr = vim.fn.bufadd("testfile")
      vim.fn.bufload(bufnr)
      vim.api.nvim_set_current_buf(bufnr)
      local original_lines = { "line one", "line two" }
      vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, original_lines)
      vim.bo[bufnr].modified = false
      set_formatter_output({ "" })
      conform.format({ formatters = { "test" }, quiet = true })
      local output_lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
      assert.are.same(original_lines, output_lines)
    end)

    it("formats on save", function()
      conform.setup({
        formatters_by_ft = { ["*"] = { "test" } },
        format_on_save = true,
      })
      vim.cmd.edit({ args = { "tests/testfile.txt" } })
      vim.api.nvim_buf_set_lines(0, 0, -1, true, { "hello" })
      set_formatter_output({ "goodbye" })
      vim.cmd.write()
      local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
      vim.fn.delete("tests/testfile.txt")
      assert.are.same({ "goodbye" }, lines)
    end)

    it("formats file even if one formatter errors", function()
      conform.formatters.test2 = {
        command = "tests/fake_formatter.sh",
        args = { "--fail" },
      }
      local lines = run_formatter("hello", "goodbye", { formatters = { "test2", "test" } })
      assert.are.same(lines, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end)

    it("does not change output if dry_run is true", function()
      run_formatter("hello", "foo", { dry_run = true })
      assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
    end)

    describe("range formatting", function()
      it("applies edits that overlap the range start", function()
        run_formatter(
          "a\nb\nc",
          "d\nb\nd",
          { range = {
            start = { 1, 0 },
            ["end"] = { 2, 0 },
          } }
        )
        assert.are.same({ "d", "b", "c" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
      end)

      it("applies edits that overlap the range end", function()
        run_formatter(
          "a\nb\nc",
          "d\nb\nd",
          { range = {
            start = { 3, 0 },
            ["end"] = { 3, 1 },
          } }
        )
        assert.are.same({ "a", "b", "d" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
      end)

      it("applies edits that are completely contained by the range", function()
        run_formatter(
          "a\nb\nc",
          "a\nd\nc",
          { range = {
            start = { 1, 0 },
            ["end"] = { 3, 0 },
          } }
        )
        assert.are.same({ "a", "d", "c" }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
      end)
    end)

    it("can run the format command in the shell", function()
      -- Mac echo doesn't seem to support -e, but the linux ci runner apparently doesn't have seq
      if fs.is_mac then
        conform.formatters.test = {
          command = "seq",
          args = "3 1 | sort",
        }
        run_formatter_test("", "1\n2\n3")
      else
        conform.formatters.test = {
          command = "echo",
          args = '-e "World\nHello" | sort',
        }
        run_formatter_test("", "Hello\nWorld")
      end
    end)
  end)
end)
