---@mod xcodebuild.xcode_logs.parser Xcode Logs Parser
---@brief [[
---This module is responsible for processing logs produced by `xcodebuild`
---commands. It parses the logs and generates a report with tests
---results, errors, and warnings.
---
---@brief ]]

---@class ParsedTest
---@field filepath string|nil The file path of the test.
---@field filename string|nil The file name of the test.
---The target name of the test.
---Could be nil when running autogenerated test plan.
---@field target string|nil
---@field class string The class name of the test.
---@field name string The test name of the test.
---@field testResult string The result of the test (passed|failed).
---@field success boolean If the test passed or failed.
---The line number of the test or
---the place where it failed.
---@field lineNumber number|nil
---@field time string|nil The formatted time it took to run the test.
---@field message string[]|nil The error message if the test failed.
---@field swiftTestingId string|nil The id of the test in SwiftTesting.

---@class ParsedBuildError
---@field filepath string
---@field filename string
---@field lineNumber number
---@field columnNumber number
---@field message string[]

---@class ParsedBuildGenericError
---@field source string|nil
---@field message string[]

---@class ParsedTestError
---@field filepath string
---@field filename string
---@field lineNumber number
---@field message string[]

---@class ParsedBuildWarning
---@field filepath string
---@field filename string
---@field lineNumber number
---@field columnNumber number
---@field message string[]

---@class ParsedReport
---@field output string[] The original logs output.
---
---Tests report.
---The key is `Target:Class` or `Class` if target
---is not available.
---@field tests table<string, ParsedTest[]>
---@field testsCount number The total number of executed tests.
---@field failedTestsCount number The total number of failed tests.
---
---The list of build errors.
---It will be injected into LSP diagnostics and
---quickfix list.
---@field buildErrors ParsedBuildError[]|ParsedBuildGenericError[]
---
---The list of build warnings.
---It will be injected into LSP diagnostics and
---quickfix list.
---@field buildWarnings ParsedBuildWarning[]
---
---The list of errors that occurred during tests
---but not in test functions. It could be for
---example some crash like force-unwrapping.
---It will be injected into LSP diagnostics and
---quickfix list.
---@field testErrors ParsedTestError[]
---@field usesSwiftTesting boolean|nil
---@field xcresultFilepath string|nil The path to the xcresult file.

local M = {}

local constants = require("xcodebuild.core.constants")
local util = require("xcodebuild.util")
local testSearch = require("xcodebuild.tests.search")

-- state machine constants
local BEGIN = "BEGIN"
local TEST_START = "TEST_START"
local TEST_ERROR = "TEST_ERROR"
local BUILD_ERROR = "BUILD_ERROR"
local BUILD_WARNING = "BUILD_WARNING"

-- temp fields
local lineType = BEGIN
local lineData = {}
local lastTest = nil
local lastErrorTest = {}
local testSuite = nil
local buildPhaseName = nil

-- report fields
local testsCount = 0
local tests = {}
local failedTestsCount = 0
local output = {}
local buildErrors = {}
local buildWarnings = {}
local testErrors = {}
local usesSwiftTesting = false
local xcresultFilepath = nil

-- patterns
local filePattern = "[^:]+%.#ALT#"
local allExtensions = {
  "swift",
  "cpp",
  "c",
  "h",
  "m",
  "mm",
  "storyboard",
  "xib",
}
local xcTestLogPattern = "%s+[%w_]+%[%d+:%d+%]"

local DEBUG = false

---Prints the message if the DEBUG flag is set to true.
---@param name string
---@param value any
local function debug_print(name, value)
  if DEBUG then
    print(name .. ":", vim.inspect(value))
  end
end

---Tries to match the string against a list of patterns created by substituting
---the give {alternatives} into the {pattern}.
---@param str string
---@param pattern string A pattern with a single '#ALT#' where the alternative should be placed.
---@param alternatives string[] A list of alternatives to substitute into the pattern.
---@return ...any
local function match_any(str, pattern, alternatives)
  for _, alternative in ipairs(alternatives) do
    local patternWithParam = pattern:gsub("#ALT#", alternative)

    if string.find(str, patternWithParam) then
      return string.match(str, patternWithParam)
    end
  end
end

---Tries to find the string against a list of patterns created by substituting
---the give {alternatives} into the {pattern}.
---@param str string
---@param pattern string A pattern with a single '#ALT#' where the alternative should be placed.
---@param alternatives string[] A list of alternatives to substitute into the pattern.
---@return boolean
local function find_any(str, pattern, alternatives)
  return match_any(str, pattern, alternatives) ~= nil
end

---Sends test data to the report results.
---
---If `message` is not nil, this function will only append the message line
---without flushing the test.
---
---It also updates the test status in the test explorer.
---@param message string|nil
---@see xcodebuild.tests.explorer
local function flush_test(message)
  debug_print("flush_test", lineData)

  if message then
    table.insert(lineData.message, message)
  end

  -- refresh test explorer
  if lineData.target and lineData.class and lineData.name then
    require("xcodebuild.tests.explorer").update_test_status(
      lineData.target .. "/" .. lineData.class .. "/" .. lineData.name,
      lineData.success and "passed" or "failed",
      {
        filepath = lineData.filepath,
        lineNumber = lineData.lineNumber,
        swiftTestingId = lineData.swiftTestingId,
      }
    )
  end

  local key = testSearch.get_test_key(lineData.target, lineData.class)
  if key then
    tests[key] = tests[key] or {}

    -- skip the same tests
    for _, item in ipairs(tests[key]) do
      if
        item.class == lineData.class
        and item.name == lineData.name
        and item.success == lineData.success
        and (not item.message or item.message[1] == lineData.message[1])
      then
        lastTest = lineData
        lineType = BEGIN
        lineData = {}
        return
      end
    end

    table.insert(tests[key], lineData)
  end

  -- refresh diagnostics and marks for the test file
  if lineData.filepath then
    local report = {
      tests = tests,
      testsCount = testsCount,
      failedTestsCount = failedTestsCount,
      buildErrors = buildErrors,
      buildWarnings = buildWarnings,
      testErrors = testErrors,
      xcresultFilepath = xcresultFilepath,
    }

    local diagnostics = require("xcodebuild.tests.diagnostics")
    diagnostics.refresh_test_buffer_by_name(lineData.filepath, report)
  end

  lastTest = lineData
  lineType = BEGIN
  lineData = {}
end

---Sends error data to the report results.
---
---If `message` is not nil, this function will only append the message line
---without flushing the error.
---@param message string|nil
local function flush_error(message)
  debug_print("flush_error", lineData)
  if message then
    table.insert(lineData.message, message)
  end

  -- skip the same errors
  for _, item in ipairs(buildErrors) do
    if
      item.filepath == lineData.filepath
      and item.lineNumber == lineData.lineNumber
      and item.message[1] == lineData.message[1]
    then
      lineType = BEGIN
      lineData = {}
      return
    end
  end

  table.insert(buildErrors, lineData)
  lineType = BEGIN
  lineData = {}
end

---Sends warning data to the report results.
---
---If `message` is not nil, this function will only append the message line
---without flushing the warning.
---@param message string|nil
local function flush_warning(message)
  debug_print("flush_warning", lineData)

  if message then
    table.insert(lineData.message, message)
  end

  for _, item in ipairs(buildWarnings) do
    if
      item.filepath == lineData.filepath
      and item.lineNumber == lineData.lineNumber
      and item.message[1] == lineData.message[1]
    then
      return
    end
  end

  table.insert(buildWarnings, lineData)
  lineType = BEGIN
  lineData = {}
end

---Sends test error data to the report results.
---It doesn't clean the `lineData`.
---It skips the same  errors.
---@param filepath string
---@param filename string
---@param lineNumber number|nil
local function flush_test_error(filepath, filename, lineNumber)
  debug_print("flush_test_error", lineData)

  for _, item in ipairs(testErrors) do
    if
      item.filepath == filepath
      and item.lineNumber == lineNumber
      and item.message[1] == lineData.message[1]
    then
      return
    end
  end

  table.insert(testErrors, {
    filepath = filepath,
    filename = filename,
    lineNumber = lineNumber,
    message = lineData.message,
  })
end

---Flushes the current `lineData` based on `lineType`.
---
---If `line` is not nil, this function will only append the message line
---@param line string|nil
local function flush(line)
  if lineType == BUILD_ERROR then
    flush_error(line)
  elseif lineType == BUILD_WARNING then
    flush_warning(line)
  elseif lineType == TEST_ERROR then
    flush_test(line)
  end
end

---Extracts the message from the log line.
---Returns the extracted message or the full line.
---@param logLine string
---@return string
local function get_message(logLine)
  return string.match(logLine, "%-%[[%w_]+%.[%w_]+ %g+%] : (.*)") or logLine
end

---Returns the first match line number or nil.
---@param filepath string
---@param testName string
---@return number|nil
local function find_test_line(filepath, testName)
  local success, lines = util.readfile(filepath)
  if not success then
    return nil
  end

  for lineNumber, line in ipairs(lines) do
    if string.find(line, "func " .. testName .. "%(") then
      return lineNumber
    elseif string.find(line, '@Test("' .. testName .. '"', nil, true) then
      return lineNumber
    end
  end

  return nil
end

---@param line string
---@see ParsedBuildError
---@see ParsedBuildGenericError
local function parse_build_error(line)
  if string.find(line, xcTestLogPattern) then
    return
  end

  if find_any(line, filePattern .. ":%d+:%d*:? %w*%s*error: .*", allExtensions) then
    local filepath, lineNumber, colNumber, message =
      match_any(line, "(" .. filePattern .. "):(%d+):(%d*):? %w*%s*error: (.*)", allExtensions)
    if filepath and message then
      lineType = BUILD_ERROR
      lineData = {
        filename = util.get_filename(filepath),
        filepath = filepath,
        lineNumber = tonumber(lineNumber),
        columnNumber = tonumber(colNumber) or 0,
        message = { message },
      }
    end
  else
    local source, message = string.match(line, "(.*): %w*%s*error: (.*)")
    message = message or string.match(line, "^error: (.*)")

    if message then
      lineType = BUILD_ERROR
      lineData = {
        source = source,
        message = { message },
      }
    end
  end

  debug_print("detected_build_error", lineData)
end

---@param line string
---@see ParsedBuildGenericError
local function parse_build_phase_error(line)
  lineType = BUILD_ERROR
  lineData = {
    message = {
      "Build phase '" .. (buildPhaseName or "Unknown") .. "' failed:",
      line,
    },
  }

  debug_print("detected_build_phase_error", lineData)
end

---@param line string
---@see ParsedBuildGenericError
local function parse_general_build_failure(line)
  lineType = BUILD_ERROR
  lineData = {
    message = { line },
  }

  debug_print("detected_general_build_failure", lineData)
end

---@param line string
---@see ParsedTest
---@see ParsedTestError
local function parse_test_error(line)
  if string.find(line, xcTestLogPattern) then
    return
  end

  local filepath, lineNumber, message =
    match_any(line, "(" .. filePattern .. "):(%d+):%d*:? %w*%s*error: (.*)", allExtensions)
  local filename = filepath and util.get_filename(filepath)
  lineData.filepath = lineData.filepath or filepath

  if string.find(line, "recorded an issue at") then
    filename, lineNumber, message =
      match_any(line, "recorded an issue at (" .. filePattern .. "):(%d+):%d+: (.*)", allExtensions)
    filepath = testSearch.find_filepath_by_filename(filename)

    lineData.filename = filename
    lineData.filepath = filepath
  elseif string.find(line, "recorded an issue") then
    filename, lineNumber, message =
      match_any(line, " at (" .. filePattern .. "):(%d+):%d+: Caught error: (.*)", allExtensions)
    filepath = testSearch.find_filepath_by_filename(filename)

    lineData.filename = filename
    lineData.filepath = filepath
  end

  if not filepath or not message then
    return
  end

  -- count only the first error per test
  if lastErrorTest == nil then
    failedTestsCount = failedTestsCount + 1

    -- we flush test with error whenever we find an empty line
    -- however, a single test can fail multiple asserts
    -- therefore, we need to remember the last test to
    -- add the next failure to the report
    lastErrorTest = util.shallow_copy(lineData)
  end

  lineType = TEST_ERROR
  lineData.message = { get_message(message) }
  lineData.testResult = "failed"
  lineData.success = false

  -- If file from error doesn't match test file, let's set lineNumber to test declaration line
  if lineData.filename and filename ~= lineData.filename then
    if lineData.filepath then
      lineData.lineNumber = find_test_line(lineData.filepath, lineData.name)
    end
    flush_test_error(filepath, filename, tonumber(lineNumber))
  else
    lineData.lineNumber = tonumber(lineNumber)
  end

  debug_print("detected_test_error", lineData)
end

---@param line string
---@see ParsedBuildWarning
local function parse_warning(line)
  if string.find(line, xcTestLogPattern) then
    return
  end

  local filepath, lineNumber, columnNumber, message =
    match_any(line, "(" .. filePattern .. "):(%d+):(%d*):? %w*%s*warning: (.*)", allExtensions)

  if filepath and message and util.has_prefix(filepath, vim.fn.getcwd()) then
    lineType = BUILD_WARNING
    lineData.filepath = filepath
    lineData.filename = util.get_filename(filepath)
    lineData.message = { message }
    lineData.lineNumber = tonumber(lineNumber) or 0
    lineData.columnNumber = tonumber(columnNumber) or 0
  end

  debug_print("detected_warning", lineData)
end

---@param line string
---@see ParsedTest
local function parse_test_finished(line)
  lastErrorTest = nil

  if
    string.find(line, '^[^%w]+ Test "[^"]+" %g+ after ')
    or string.find(line, "^[^%w]+ Test %g+ %g+ after ")
  then
    local testResult, time
    if string.find(line, '"') then
      testResult, time = string.match(line, '^[^%w]+ Test "[^"]+" (%g+) after (%g+ %w+)')
    else
      testResult, time = string.match(line, "^[^%w]+ Test %g+ (%g+) after (%g+ %w+)")
    end

    if lastTest then
      lastTest.time = time
      lastTest.testResult = testResult
      lastTest.success = testResult == "passed"
      lastTest = nil
      lineData = {}
      lineType = BEGIN
    else
      lineData.time = time
      lineData.testResult = testResult
      lineData.success = testResult == "passed"
      flush_test()
    end
  elseif string.find(line, "^Test Case .*.%-") then
    local testResult, time = string.match(line, "^Test Case .*.%-%[[%w_]+%.[%w_]+ %g+%]. (%w+)% %((.*)%)%.")
    if lastTest then
      lastTest.time = time
      lastTest.testResult = testResult
      lastTest.success = testResult == "passed"
      lastTest = nil
      lineData = {}
      lineType = BEGIN
    else
      lineData.time = time
      lineData.testResult = testResult
      lineData.success = testResult == "passed"
      flush_test()
    end
  else -- when running with autogenerated plan or parallel testing
    local testClass, testName, testResult, time =
      string.match(line, "^Test case %'([%w_]+)[%.%/](%g+)%(.*%' (%w+) .* %(([^%)]*)%)$")

    -- SwiftTesting - test without suite
    if not testClass and not testName then
      testName, testResult, time = string.match(line, "^Test case %'(%g+)%(.*%' (%w+) .* %(([^%)]*)%)$")
      if testName and testResult then
        testClass = constants.SwiftTestingGlobal
      end
    end

    if testClass and testName and testResult then
      local filepath = testSearch.find_filepath("", testClass)

      lineData = {
        filepath = filepath,
        filename = filepath and util.get_filename(filepath) or nil,
        target = filepath and testSearch.find_target_for_file(filepath),
        class = testClass,
        name = testName,
        lineNumber = filepath and find_test_line(filepath, testName) or nil,
        testResult = testResult,
        success = testResult == "passed",
        time = time,
      }
      testsCount = testsCount + 1
      if not lineData.success then
        lineData.message = { "Failed" }
        failedTestsCount = failedTestsCount + 1
      end
      flush_test()
    end
  end
end

---@param line string
---@see ParsedTest
local function parse_test_started(line)
  local target, testClass, testName = string.match(line, "^Test Case .*.%-%[([%w_]+)%.([%w_]+) (%g+)%]")
  if not testName then
    target = constants.SwiftTestingTarget
    testClass = testSuite or constants.SwiftTestingGlobal
    testName = string.match(line, '^[^%w]+ Test "([^"]+)"')
      or string.match(line, "^[^%w]+ Test (%g+)%([^%)]*%)")
    testName = testName and testName:gsub("/", " ")
  end

  local filepath = testClass and testSearch.find_filepath(target, testClass)

  testsCount = testsCount + 1
  lastErrorTest = nil
  lastTest = nil
  lineType = TEST_START
  lineData = {
    filepath = filepath,
    filename = filepath and util.get_filename(filepath) or nil,
    target = target,
    class = testClass,
    name = testName,
  }

  debug_print("detected_test_started", lineData)
end

---Processes the log line and updates the state machine.
---@param line string
local function process_line(line)
  -- POSSIBLE PATHS:
  -- BEGIN -> BUILD_ERROR -> BEGIN
  -- BEGIN -> BUILD_WARNING -> BEGIN
  -- BEGIN -> TEST_START -> passed -> BEGIN
  -- BEGIN -> TEST_START -> TEST_ERROR -> (failed) -> BEGIN

  if string.find(line, "◇ Test run started") then
    if not usesSwiftTesting then
      --- We need to clear Test Explorer because post processed Swift Testing tests
      --- don't match the ones produced in logs.
      --- Just let them appear while running and fix the list after the run.
      require("xcodebuild.tests.explorer").clear(false)
    end
    usesSwiftTesting = true
  elseif string.find(line, '◇ Suite "[^"]+" started') then
    testSuite = string.match(line, '◇ Suite "(.+)" started')
    testSuite = testSuite and testSuite:gsub("/", " ")
  elseif string.find(line, "◇ Suite .+ started") then
    testSuite = string.match(line, "◇ Suite (.+) started")
  elseif string.find(line, "^[^%w]+ Suite ") then
    testSuite = nil
  elseif string.find(line, "^Test Case.*started") or string.find(line, "^[^%w]+ Test .+ started") then
    -- build is finished - now it's time to load targets
    if testsCount == 0 then
      testSearch.load_targets_map()
    end

    parse_test_started(line)
  elseif string.find(line, "◇ Passing") then
    -- do nothing
    return
  elseif
    string.find(line, "^Test [Cc]ase.*passed")
    or string.find(line, "^Test [Cc]ase.*failed")
    or string.find(line, "^[^%w]+ Test %g+ passed")
    or string.find(line, "^[^%w]+ Test %g+ failed")
    or string.find(line, '^[^%w]+ Test "[^"]+" passed')
    or string.find(line, '^[^%w]+ Test "[^"]+" failed')
  then
    flush() -- flush if there is anything
    line = line:gsub("on '[^']*'", "") -- remove simulator name (it appears while parallel testing)
    parse_test_finished(line)
  elseif string.find(line, "error:") or string.find(line, "recorded an issue") then
    flush()

    -- found another failure within the same test
    -- restore previous data
    if testsCount > 0 and lineType == BEGIN and lastErrorTest then
      lineData = lastErrorTest
      lineType = TEST_START
      parse_test_error(line)
    elseif lineType == TEST_START then
      parse_test_error(line)
    elseif testsCount == 0 and lineType == BEGIN then
      parse_build_error(line)
    end
  elseif string.find(line, "PhaseScriptExecution failed") then
    flush()
    parse_build_phase_error(line)
    flush()
    buildPhaseName = nil
  elseif string.find(line, "warning:") then
    flush()
    parse_warning(line)
  elseif string.find(line, "%s*~*%^~*%s*") then
    flush(line)
  elseif string.find(line, "^PhaseScriptExecution") then
    buildPhaseName = string.match(line, "^PhaseScriptExecution ([^/]+) /")
    buildPhaseName = buildPhaseName and buildPhaseName:gsub("\\", "")
  elseif string.find(line, "** BUILD FAILED **") and util.is_empty(buildErrors) then
    parse_general_build_failure(line)
    flush()
  elseif string.find(line, "^%s*$") then
    -- test errors can contain multiple lines with empty lines
    -- we'll flush when we encounter finished test line.
    if lineType ~= TEST_ERROR then
      flush()
    end
  elseif string.find(line, "^Linting") or string.find(line, "^note:") then
    flush()
  elseif string.find(line, "%.xcresult$") then
    xcresultFilepath = string.match(line, "%s*(.*[^%.%/]+%.xcresult)")
  elseif lineType == TEST_ERROR or lineType == BUILD_ERROR or lineType == BUILD_WARNING then
    table.insert(lineData.message, line)
  end
end

---Clears the parser state.
function M.clear()
  lastTest = nil
  lastErrorTest = nil
  testSuite = nil
  lineData = {}
  lineType = BEGIN

  tests = {}
  testsCount = 0
  failedTestsCount = 0
  output = {}
  buildErrors = {}
  buildWarnings = {}
  testErrors = {}
  usesSwiftTesting = false
  xcresultFilepath = nil
end

---Parses Xcode logs and generates the report with tests results,
---errors, and warnings.
---
---Returns partial report based on currently processed logs.
---Calling it multiple times appends the logs to the report, and
---returns the updated report.
---
---To process new logs, call `clear` before calling this function.
---@param logLines string[] Xcode log lines.
---@return ParsedReport
function M.parse_logs(logLines)
  local newLines = {}
  local logsPanel = require("xcodebuild.xcode_logs.panel")
  if not next(output) then
    logsPanel.clear()
  end

  for _, line in ipairs(logLines) do
    process_line(line)
  end

  if next(output) and next(logLines) then
    output[#output] = output[#output] .. logLines[1]
    newLines = { output[#output] }
    table.remove(logLines, 1)
  end

  for _, line in ipairs(logLines) do
    table.insert(output, line)
    table.insert(newLines, line)
  end

  -- skip the last line (it will be joined in the next iteration)
  if next(newLines) then
    table.remove(newLines, #newLines)
  end

  if require("xcodebuild.core.config").options.logs.live_logs then
    logsPanel.append_log_lines(newLines)
  end

  ---@type ParsedReport
  return {
    output = output,
    tests = tests,
    testsCount = testsCount,
    failedTestsCount = failedTestsCount,
    buildErrors = buildErrors,
    buildWarnings = buildWarnings,
    testErrors = testErrors,
    usesSwiftTesting = usesSwiftTesting,
    xcresultFilepath = xcresultFilepath,
  }
end

return M
