local logger = require("common.lib.logger")
local tableUtils = require("common.lib.tableUtils")
local system = require("client.src.system")
local json = require("common.lib.dkjson")

local PREFIX_OF_IGNORED_DIRECTORIES = "__"

-- Collection of functions for file operations
local fileUtils = {}

fileUtils.SUPPORTED_IMAGE_FORMATS = {".png", ".jpg", ".jpeg"}
fileUtils.SUPPORTED_SOUND_FORMATS = {".mp3", ".ogg", ".wav", ".flac", ".699", ".amf", ".ams", ".dbm", ".dmf", ".dsm", ".far", ".it", ".j2b", ".mdl", ".med", ".mod", ".mt2", ".mtm", ".okt", ".psm", ".s3m", ".stm", ".ult", ".umx", ".xm"}

-- returns the directory items with a default filter and an optional filetype filter
-- by default, filters out everything starting with __ and Mac's .DS_Store file
-- optionally the result can be filtered to return only "file" or "directory" items
---@param path string
---@param fileType ("file" | "directory")?
function fileUtils.getFilteredDirectoryItems(path, fileType)
  local results = {}

  local directoryList = love.filesystem.getDirectoryItems(path)
  for _, file in ipairs(directoryList) do

    local startOfFile = string.sub(file, 0, string.len(PREFIX_OF_IGNORED_DIRECTORIES))
   -- macOS sometimes puts these files in folders without warning, they are never useful for PA, so filter them.
    if startOfFile ~= PREFIX_OF_IGNORED_DIRECTORIES and file ~= ".DS_Store" then
      if not fileType or love.filesystem.getInfo(path .. "/" .. file, fileType) then
        results[#results+1] = file
      end
    end
  end

  return results
end

-- recursively goes down all folders and returns all non folder files as a tuple of directory and filename
---@param path string
---@return {directory: string, filename: string}[]
function fileUtils.getFilteredFilesRecursive(path)
  local results = {}

  for _, filename in ipairs(fileUtils.getFilteredDirectoryItems(path, "file")) do
    results[#results+1] = {directory=path, filename=filename}
  end
  for _, subDirectory in ipairs(fileUtils.getFilteredDirectoryItems(path, "directory")) do
    for _, record in ipairs(fileUtils.getFilteredFilesRecursive(path .. "/" .. subDirectory)) do
      results[#results+1] = {directory=record.directory, filename=record.filename}
    end
  end

  return results
end

function fileUtils.getFileNameWithoutExtension(filename)
  return filename:gsub("%..*", "")
end

-- copies a file from the given source to the given destination
function fileUtils.copyFile(source, destination)
  local source_file, sizeError = love.filesystem.read(source)
  if source_file == nil then
    return false, sizeError
  end
  local success, error = love.filesystem.write(destination, source_file)
  return success, error
end

-- copies a file from the given source to the given destination
function fileUtils.recursiveCopy(source, destination, yields)
  local lfs = love.filesystem
  local names = lfs.getDirectoryItems(source)
  local temp
  for i, name in ipairs(names) do
    local info = lfs.getInfo(source .. "/" .. name)
    if info and info.type == "directory" then
      logger.trace("calling recursive_copy(source" .. "/" .. name .. ", " .. destination .. "/" .. name .. ")")
      fileUtils.recursiveCopy(source .. "/" .. name, destination .. "/" .. name, yields)
    elseif info and info.type == "file" then
      local destination_info = lfs.getInfo(destination)
      if not destination_info or destination_info.type ~= "directory" then
        love.filesystem.createDirectory(destination)
      end
      logger.trace("copying file:  " .. source .. "/" .. name .. " to " .. destination .. "/" .. name)

      local success, message = fileUtils.copyFile(source .. "/" .. name, destination .. "/" .. name)

      if not success then
        logger.warn(message)
      end
    else
      logger.warn("name:  " .. name .. " isn't a directory or file?")
    end
  end

  if yields then
    coroutine.yield("Copied\n" .. source .. "\nto\n" .. destination)
  end
end

-- Deletes any file matching the target name from the file tree recursively
function fileUtils.recursiveRemoveFiles(folder, targetName)
  local lfs = love.filesystem
  local filesTable = lfs.getDirectoryItems(folder)
  for _, fileName in ipairs(filesTable) do
    local file = folder .. "/" .. fileName
    local info = lfs.getInfo(file)
    if info then
      if info.type == "directory" then
        fileUtils.recursiveRemoveFiles(file, targetName)
      elseif info.type == "file" and fileName == targetName then
        love.filesystem.remove(file)
      end
    end
  end
end

-- returns the table for the deserialized json at the specified file path
---@param file string
---@return table? # nil if the file could not be read or deserialization failed
function fileUtils.readJsonFile(file)
  if not love.filesystem.getInfo(file, "file") then
    logger.debug("No file at specified path " .. file)
    return nil
  else
    local fileContent, info = love.filesystem.read(file)
    if type(info) == "string" then
      -- info is the number of read bytes if successful, otherwise an error string
      -- thus, if it is of type string, that indicates an error
      logger.warn("Could not read file at path " .. file)
      return nil
    else
      local value, _, errorMsg = json.decode(fileContent)
      if errorMsg then
        logger.error("Error reading " .. file .. ":\n" .. errorMsg .. ":\n" .. fileContent)
        return nil
      else
        ---@cast value table
        return value
      end
    end
  end
end

--returns a source, or nil if it could not find a file
---@param path_and_filename string
---@param streamed boolean?
---@return love.Source?
function fileUtils.loadSoundFromSupportExtensions(path_and_filename, streamed)
  for k, extension in ipairs(fileUtils.SUPPORTED_SOUND_FORMATS) do
    if love.filesystem.exists(path_and_filename .. extension) then
      return love.audio.newSource(path_and_filename .. extension, streamed and "stream" or "static")
    end
  end
  return nil
end

---@param path string the path to the file without the filename itself
---@param exactFilename string the filename including extension
---@return love.SoundData?
function fileUtils.loadSoundData(path, exactFilename)
  local fullPath = path .. "/" .. exactFilename
  local info = love.filesystem.getInfo(fullPath)
  if info then
    local buffersize = 4096
    local decoder = love.sound.newDecoder(fullPath, buffersize)
    local sampleRate = decoder:getSampleRate()
    local chunks = {}
    local channelCount = decoder:getChannelCount()
    local chunk = decoder:decode()
    -- basically limiting decoding to files that were encoded to more than 0.2% of their real size (I think...a conservative limit anyway)
    local chunkLimit = math.ceil(info.size / buffersize) * 500
    local totalSampleCount = 0
    while chunk and #chunks <= chunkLimit do
      totalSampleCount = totalSampleCount + chunk:getSampleCount()
      chunks[#chunks + 1] = chunk
      chunk = decoder:decode()
    end

    if chunk and #chunks > chunkLimit then
      error("Failed to load " .. fullPath ..
            "\ndata seems to loop infinitely")
    end

    local soundData = love.sound.newSoundData(totalSampleCount, sampleRate, decoder:getBitDepth(), channelCount)
    local position = 0
    if system.meetsLoveVersionRequirement(12, 0) then
      for i, chunk in ipairs(chunks) do
        local sampleCount = chunk:getSampleCount()
        soundData:copyFrom(chunk, 0, sampleCount, position)
        position = position + sampleCount
      end
    else
      for i, chunk in ipairs(chunks) do
        for j = 0, chunk:getSampleCount() - 1 do
          for channel = 1, channelCount do
            local sample = chunk:getSample(j, channel)
            soundData:setSample(position, channel, sample)
          end
          position = position + 1
        end
      end
    end

    return soundData
  end
end

-- returns a new sound effect if it can be found, else returns nil
---@param sound_name string the file name without extension we're looking for
---@param dirs_to_check string[] the directories that are searched for the file
---@param streamed boolean? true if the source should be loaded as a stream, false/nil if static
---@return love.Source?
function fileUtils.findSound(sound_name, dirs_to_check, streamed)
  streamed = streamed or false
  local found_source
  for k, dir in ipairs(dirs_to_check) do
    found_source = fileUtils.loadSoundFromSupportExtensions(dir .. sound_name, streamed)
    if found_source then
      return found_source
    end
  end
  return nil
end

---@param soundName string
---@param path string
---@return string? soundNameWithExtension
function fileUtils.getSoundFileName(soundName, path)
  local p = path .. "/" .. soundName
  for _, extension in pairs(fileUtils.SUPPORTED_SOUND_FORMATS) do
    if love.filesystem.exists(p .. extension) then
      return soundName .. extension
    end
  end
end

function fileUtils.saveTextureToFile(texture, filePath, format)
  local loveMajor = love.getVersion()

  local imageData
  if loveMajor >= 12 then
    imageData = love.graphics.readbackTexture(texture)
  else
    -- this code branch is untested but the function is also not used in production at the moment
    if texture:typeOf("Canvas") then
      imageData = texture:newImageData()
    else
      local canvas = love.graphics.newCanvas(texture:getDimensions())
      local currentCanvas = love.graphics.getCanvas()
      love.graphics.setCanvas(canvas)
      love.graphics.draw(texture)
      love.graphics.setCanvas(currentCanvas)
      imageData = canvas:newImageData()
    end
  end

  local data = imageData:encode(format)
  --- Not sure if this is right, revisit
  ---@diagnostic disable-next-line: param-type-mismatch
  love.filesystem.write(filePath .. "." .. format, data)
end

---@param replay ReplayV3
function fileUtils.saveReplay(replay)
  local path = replay:generatePath("/")
  local filename = replay:generateFileName()
  GAME.lastReplayPath = path
  fileUtils.writeJson(path, filename .. ".json", replay, replay.keyOrder)
end

---@param files string[]
---@param pattern string
---@param validExtensions string[]
---@param separator string?
---@return string[] # files filtered down to only strings matching the specified pattern, separator and valid extension list
function fileUtils.getMatchingFiles(files, pattern, validExtensions, separator)
  separator = separator or ""
  local stringLen = string.len(pattern)
  local matchedFiles = tableUtils.filter(files,
  function(file)
    local startIndex, endIndex = string.find(file, pattern, nil, true)
    if not startIndex then
      return false
    elseif startIndex > 1 then
      -- this means the name is prefixed with something else
      return false
    else
      local goodExtension
      -- this check is doubly good because it enforces lower case extensions even on windows
      for i, extension in ipairs(validExtensions) do
        local length = extension:len()
        if file:sub(-length) == extension then
          goodExtension = extension
          break
        end
      end
      if not goodExtension then
        return false
      else
        -- now check for actual exact matching:
        -- first cut off the matching part
        local middlePart = file:sub(stringLen + 1)
        -- and then the extension
        middlePart = middlePart:sub(1, - goodExtension:len() - 1)
        if middlePart:len() == 0 then
          -- this is just the exact pattern + file extension
          return true
        else
          local sepLen = separator:len()
          if middlePart:sub(1, sepLen) ~= separator then
            return false
          else
            local numberPart = middlePart:sub(sepLen + 1)
            -- we need to string.match on top of casting tonumber because of Lua accepting scientific notation strings as numbers
            if string.match(numberPart, "%d+") == numberPart and tonumber(numberPart) then
              -- there are really only digits that form a number in the number part
              return true
            else
              return false
            end
          end
        end
      end
    end
  end)

  return matchedFiles
end

---@param path string
---@param data string
function fileUtils.write(path, filename, data)
  love.filesystem.createDirectory(path)
  local success, message = love.filesystem.write(path .. "/" .. filename, data)
  if not success then
    error("Failed to write to " .. path .. " : " .. message)
  end
end

-- Custom JSON prettifier that provides enhanced formatting beyond DKJson's standard indent option
-- Key differences from DKJson's built-in indent:
-- 1. Always adds newlines after opening braces/brackets (DKJson may keep simple objects on one line)
-- 2. Special handling for arrays after colons - places arrays on new indented lines
-- 3. Custom whitespace management - strips original spacing and applies consistent 2-space indentation
-- 4. More aggressive line breaking for better readability of complex nested structures
-- 5. Handles colon spacing differently - uses ': ' for values but ':\n' + indent for arrays
-- This is used when encodeArgs.pretty and encodeArgs.indent are both true to provide
-- more readable output than DKJson's standard formatting
---@param jsonString string The compact JSON string (typically from json.encode) to format
---@return string The formatted JSON string with enhanced indentation and line breaks
local function prettifyJson(jsonString)
  local result = {}
  local i = 1
  local len = #jsonString
  local inString = false
  local escaped = false
  local indentLevel = 0
  
  -- Helper function to generate indentation string
  local function getIndentation()
    return string.rep("  ", indentLevel)
  end
  
  while i <= len do
    local char = jsonString:sub(i, i)
    
    if escaped then
      escaped = false
    elseif char == '\\' and inString then
      escaped = true
    elseif char == '"' then
      inString = not inString
    elseif not inString then
      if char == '{' then
        table.insert(result, char)
        indentLevel = indentLevel + 1
        -- Always add newline after opening brace
        table.insert(result, '\n' .. getIndentation())
        i = i + 1
        goto continue
      elseif char == '[' then
        table.insert(result, char)
        indentLevel = indentLevel + 1
        -- Always add newline after opening bracket
        table.insert(result, '\n' .. getIndentation())
        i = i + 1
        goto continue
      elseif char == '}' or char == ']' then
        indentLevel = indentLevel - 1
        table.insert(result, '\n' .. getIndentation() .. char)
        i = i + 1
        goto continue
      elseif char == ',' then
        table.insert(result, char)
        table.insert(result, '\n' .. getIndentation())
        i = i + 1
        goto continue
      elseif char == ':' then
        -- Look ahead to see if next non-whitespace is '['
        local j = i + 1
        local nextNonSpace = nil
        while j <= len do
          local nextChar = jsonString:sub(j, j)
          if nextChar ~= ' ' and nextChar ~= '\t' and nextChar ~= '\n' then
            nextNonSpace = nextChar
            break
          end
          j = j + 1
        end
        
        if nextNonSpace == '[' then
          -- For arrays, put colon, newline, then indentation
          table.insert(result, ':\n' .. getIndentation())
        else
          -- For other values, just colon and space
          table.insert(result, ': ')
        end
        i = i + 1
        goto continue
      elseif char == '\n' or char == '\t' or char == ' ' then
        -- Skip whitespace from the original JSON since we manage our own formatting
        i = i + 1
        goto continue
      end
    end
    
    table.insert(result, char)
    i = i + 1
    ::continue::
  end
  
  return table.concat(result)
end

-- encodes the table into json and writes it to the filename at the given path
-- throws an error on failure so it should be assumed the write always succeeds
---@param path string
---@param filename string
---@param tab table
---@param encodeArgs ({indent: boolean, pretty: boolean, keyorder: string[], level: integer} | nil)
function fileUtils.writeJson(path, filename, tab, encodeArgs)
  local encoded = json.encode(tab, encodeArgs)
  ---@cast encoded string # json.encode always returns a string if the second argument does not contain the buffer field
  if encodeArgs and encodeArgs.pretty and encodeArgs.indent then
    encoded = prettifyJson(encoded)
  end
  fileUtils.write(path, filename, encoded)
end

fileUtils.prettifyJson = prettifyJson

return fileUtils