# frozen_string_literal: true

module RedmineUxToolkit
  module Services
    class LogFiles
      SUPPORTED_LINE_COUNTS = [100, 250, 500].freeze
      DEFAULT_LINE_COUNT = 100
      READ_CHUNK_SIZE = 16 * 1024

      LogFile = Struct.new(:name, :path, :size, :modified_at, keyword_init: true)

      class AccessDenied < StandardError; end
      class NotFound < StandardError; end
      class NotReadable < StandardError; end
      class NotWritable < StandardError; end

      def log_files
        direct_log_files.select {|file| readable_file?(file.path)}
      end

      def selected_file(filename)
        return log_files.first if filename.blank?

        safe_filename = safe_basename(filename)
        raise AccessDenied unless safe_filename

        file = direct_log_files.detect {|log_file| log_file.name == safe_filename}
        raise NotFound unless file
        raise NotReadable unless readable_file?(file.path)

        file
      end

      def normalize_line_count(value)
        count = value.to_i

        SUPPORTED_LINE_COUNTS.include?(count) ? count : DEFAULT_LINE_COUNT
      end

      def tail(filename, line_count)
        file = selected_file(filename)
        return '' if file.size.to_i.zero?

        read_tail(file.path, normalize_line_count(line_count))
      end

      def flush(filename)
        file = selected_file(filename)
        raise NotWritable unless file.path.writable?

        File.open(file.path, File::WRONLY) {|log_file| log_file.truncate(0)}
      rescue Errno::EACCES, Errno::EROFS
        raise NotWritable
      rescue Errno::ENOENT
        raise NotFound
      end

      private

      def direct_log_files
        return [] unless log_directory&.directory?

        log_directory.children.select {|path| regular_direct_file?(path) }.map do |path|
          log_file_for(path.basename.to_s)
        end.compact.sort_by(&:name)
      end

      def log_file_for(filename)
        path = direct_child_path(filename)
        return nil unless path && regular_direct_file?(path)

        stat = path.stat
        LogFile.new(
          name: filename,
          path: path,
          size: stat.size,
          modified_at: stat.mtime
        )
      rescue Errno::ENOENT, Errno::EACCES
        nil
      end

      def direct_child_path(filename)
        safe_filename = safe_basename(filename)
        raise AccessDenied unless safe_filename
        return nil unless log_directory

        path = log_directory.join(safe_filename).expand_path
        raise AccessDenied unless path.dirname == log_directory

        path
      end

      def safe_basename(filename)
        name = filename.to_s
        return nil if name.blank?
        return nil unless File.basename(name) == name

        name
      end

      def log_directory
        @log_directory ||= begin
          path = Rails.root.join('log')
          path.directory? ? path.realpath : nil
        rescue Errno::ENOENT, Errno::EACCES
          nil
        end
      end

      def readable_file?(path)
        regular_direct_file?(path) && path.readable?
      rescue Errno::ENOENT, Errno::EACCES
        false
      end

      def regular_direct_file?(path)
        path.lstat.file?
      rescue Errno::ENOENT, Errno::EACCES
        false
      end

      def read_tail(path, line_count)
        buffer = +''

        File.open(path, 'rb') do |file|
          position = file.size

          while position.positive? && buffer.count("\n") <= line_count
            read_size = [READ_CHUNK_SIZE, position].min
            position -= read_size

            file.seek(position)
            buffer.prepend(file.read(read_size).to_s)
          end
        end

        buffer.lines.last(line_count).join.encode('UTF-8', invalid: :replace, undef: :replace)
      rescue Errno::ENOENT
        raise NotFound
      rescue Errno::EACCES
        raise NotReadable
      end
    end
  end
end
