# frozen_string_literal: true

# An InnoDB space file, which can be either a multi-table ibdataN file
# or a single-table "innodb_file_per_table" .ibd file.
module Innodb
  class Space
    # InnoDB's default page size is 16KiB.
    DEFAULT_PAGE_SIZE = 16 * 1024

    # The default extent size is 1 MiB defined originally as 64 pages.
    DEFAULT_EXTENT_SIZE = 64 * DEFAULT_PAGE_SIZE

    # A map of InnoDB system space fixed-allocation pages. This can be used to
    # check whether a space is a system space or not, as non-system spaces will
    # not match this pattern.
    SYSTEM_SPACE_PAGE_MAP = {
      0 => :FSP_HDR,
      1 => :IBUF_BITMAP,
      2 => :INODE,
      3 => :SYS,
      4 => :INDEX,
      5 => :TRX_SYS,
      6 => :SYS,
      7 => :SYS,
    }.freeze

    XDES_LISTS = %i[
      free
      free_frag
      full_frag
    ].freeze

    # An array of Innodb::Inode list names.
    INODE_LISTS = %i[
      full_inodes
      free_inodes
    ].freeze

    class DataFile
      attr_reader :file
      attr_reader :size
      attr_reader :offset

      def initialize(filename, offset)
        @file = File.open(filename)
        @size = @file.stat.size
        @offset = offset
      end

      def name
        file.path
      end
    end

    # Open a space file, optionally providing the page size to use. Pages
    # that aren't 16 KiB may not be supported well.
    def initialize(filenames, innodb_system: nil)
      filenames = [filenames] unless filenames.is_a?(Array)

      @data_files = []
      @size = 0
      filenames.each do |filename|
        file = DataFile.new(filename, @size)
        @size += file.size
        @data_files << file
      end

      @system_page_size = fsp_flags.system_page_size
      @page_size        = fsp_flags.page_size
      @compressed       = fsp_flags.compressed

      @pages = (@size / @page_size)
      @innodb_system = innodb_system
      @record_describer = nil
    end

    # The Innodb::System to which this space belongs, if any.
    attr_accessor :innodb_system

    # An object which can be used to describe records found in pages within
    # this space.
    attr_accessor :record_describer

    # The system default page size (in bytes), equivalent to UNIV_PAGE_SIZE.
    attr_reader :system_page_size

    # The size (in bytes) of each page in the space.
    attr_reader :page_size

    # The size (in bytes) of the space
    attr_reader :size

    # The number of pages in the space.
    attr_reader :pages

    # Return a string which can uniquely identify this space. Be careful not
    # to do anything which could instantiate a BufferCursor so that we can use
    # this method in cursor initialization.
    def name
      @name ||= @data_files.map(&:name).join(",")
    end

    def inspect
      "<%s file=%s, page_size=%i, pages=%i>" % [
        self.class.name,
        name.inspect,
        page_size,
        pages,
      ]
    end

    # Read the FSP header "flags" field by byte offset within the space file.
    # This is useful in order to initialize the page size, as we can't properly
    # read the FSP_HDR page before we know its size.
    def raw_fsp_header_flags
      # A simple sanity check. The FIL header should be initialized in page 0,
      # to offset 0 and page type :FSP_HDR (8).
      page_offset = BinData::Uint32be.read(read_at_offset(4, 4)).to_i
      page_type   = BinData::Uint16be.read(read_at_offset(24, 2)).to_i
      unless page_offset.zero? && Innodb::Page::PAGE_TYPE_BY_VALUE[page_type] == :FSP_HDR
        raise "Something is very wrong; Page 0 does not seem to be type FSP_HDR; got page type %i but expected %i" % [
          page_type,
          Innodb::Page::PAGE_TYPE[:FSP_HDR][:value],
        ]
      end

      # Another sanity check. The Space ID should be the same in both the FIL
      # and FSP headers.
      fil_space = BinData::Uint32be.read(read_at_offset(34, 4)).to_i
      fsp_space = BinData::Uint32be.read(read_at_offset(38, 4)).to_i
      unless fil_space == fsp_space
        raise "Something is very wrong; FIL and FSP header Space IDs do not match: FIL is %i but FSP is %i" % [
          fil_space,
          fsp_space,
        ]
      end

      # Well, we're as sure as we can be. Read the flags field and decode it.
      flags_value = BinData::Uint32be.read(read_at_offset(54, 4))
      Innodb::Page::FspHdrXdes.decode_flags(flags_value)
    end

    # The FSP header flags, decoded. If the page size has not been initialized,
    # reach into the raw bytes of the FSP_HDR page and attempt to decode the
    # flags field that way.
    def fsp_flags
      return fsp.flags if @page_size

      raw_fsp_header_flags
    end

    # The number of pages per extent.
    def pages_per_extent
      # Note that uncompressed tables and compressed tables using the same page
      # size will have a different number of pages per "extent" because InnoDB
      # compression uses the FSP_EXTENT_SIZE define (which is then based on the
      # UNIV_PAGE_SIZE define, which may be based on the innodb_page_size system
      # variable) for compressed tables rather than something based on the actual
      # compressed page size.
      #
      # For this reason, an "extent" differs in size as follows (the maximum page
      # size supported for compressed tables is the innodb_page_size):
      #
      #   innodb_page_size                | innodb compression              |
      #   page size | extent size | pages | page size | extent size | pages |
      #   16384     | 1 MiB       | 64    | 16384     | 1 MiB       | 64    |
      #                                   | 8192      | 512 KiB     | 64    |
      #                                   | 4096      | 256 KiB     | 64    |
      #                                   | 2048      | 128 KiB     | 64    |
      #                                   | 1024      | 64 KiB      | 64    |
      #   8192      | 1 MiB       | 128   | 8192      | 1 MiB       | 128   |
      #                                   | 4096      | 512 KiB     | 128   |
      #                                   | 2048      | 256 KiB     | 128   |
      #                                   | 1024      | 128 KiB     | 128   |
      #   4096      | 1 MiB       | 256   | 4096      | 1 MiB       | 256   |
      #                                   | 2048      | 512 KiB     | 256   |
      #                                   | 1024      | 256 KiB     | 256   |
      #

      DEFAULT_EXTENT_SIZE / system_page_size
    end

    # The size (in bytes) of an extent.
    def extent_size
      pages_per_extent * page_size
    end

    # The number of pages per FSP_HDR/XDES/IBUF_BITMAP page. This is crudely
    # mapped to the page size, and works for pages down to 1KiB.
    def pages_per_bookkeeping_page
      page_size
    end

    # The FSP_HDR/XDES page which will contain the XDES entry for a given page.
    def xdes_page_for_page(page_number)
      page_number - (page_number % pages_per_bookkeeping_page)
    end

    # The IBUF_BITMAP page which will contain the bitmap entry for a given page.
    def ibuf_bitmap_page_for_page(page_number)
      page_number - (page_number % pages_per_bookkeeping_page) + 1
    end

    # The XDES entry offset for a given page within its FSP_HDR/XDES page's
    # XDES array.
    def xdes_entry_for_page(page_number)
      relative_page_number = page_number - xdes_page_for_page(page_number)
      relative_page_number / pages_per_extent
    end

    # Return the Innodb::Xdes entry which represents a given page.
    def xdes_for_page(page_number)
      xdes_array = page(xdes_page_for_page(page_number)).each_xdes.to_a
      xdes_array[xdes_entry_for_page(page_number)]
    end

    def data_file_for_offset(offset)
      @data_files.each do |file|
        return file if offset < file.size

        offset -= file.size
      end
      nil
    end

    # Get the raw byte buffer of size bytes at offset in the file.
    def read_at_offset(offset, size)
      return nil unless offset < @size && (offset + size) <= @size

      data_file = data_file_for_offset(offset)
      data_file.file.seek(offset - data_file.offset)
      data_file.file.read(size)
    end

    # Get the raw byte buffer for a specific page by page number.
    def page_data(page_number)
      read_at_offset(page_number * page_size, page_size)
    end

    # Get an Innodb::Page object for a specific page by page number.
    def page(page_number)
      data = page_data(page_number)
      Innodb::Page.parse(self, data, page_number) if data
    end

    # Determine whether this space looks like a system space. If the initial
    # pages in the space match the SYSTEM_SPACE_PAGE_MAP, it is likely to be
    # a system space.
    def system_space?
      SYSTEM_SPACE_PAGE_MAP.each do |page_number, type|
        # We can't use page() here, because system_space? need to be used
        # in the Innodb::Page::Sys.parse to determine what type of page
        # is being looked at. Using page() would cause us to keep recurse
        # infinitely. Use Innodb::Page.new instead to load the page as
        # simply as possible.
        test_page = Innodb::Page.new(self, page_data(page_number))
        return false unless test_page.type == type
      end
      true
    end

    # Return the page number for the space's FSP_HDR page.
    def page_fsp_hdr
      0
    end

    # Get (and cache) the FSP header from the FSP_HDR page.
    def fsp
      @fsp ||= page(page_fsp_hdr).fsp_header
    end

    def space_id
      fsp[:space_id]
    end

    def checked_page_class!(page, expected_class)
      return page if page.instance_of?(expected_class)

      raise "Page #{page.offset} is not the correct type, found: #{page.class}, expected: #{expected_class}"
    end

    # Return the page number for the space's TRX_SYS page.
    def page_trx_sys
      5
    end

    # Get the Innodb::Page::TrxSys page for a system space.
    def trx_sys
      raise "Transaction System is only available in system spaces" unless system_space?

      checked_page_class!(page(page_trx_sys), Innodb::Page::TrxSys)
    end

    def rseg_page?(page_number)
      return false unless trx_sys

      trx_sys.rsegs.any? { |rseg| rseg.space_id.zero? && rseg.page_number == page_number }
    end

    # Return the page number for the space's SYS data dictionary header.
    def page_sys_data_dictionary
      7
    end

    # Get the Innodb::Page::SysDataDictionaryHeader page for a system space.
    def data_dictionary_page
      raise "Data Dictionary is only available in system spaces" unless system_space?

      checked_page_class!(page(page_sys_data_dictionary), Innodb::Page::SysDataDictionaryHeader)
    end

    # Get an Innodb::List object for a specific list by list name.
    def list(name)
      fsp[name] if XDES_LISTS.include?(name) || INODE_LISTS.include?(name)
    end

    def sdi
      @sdi ||= Innodb::Sdi.new(self)
    end

    def sdi?
      sdi.valid?
    end

    # Get an Innodb::Index object for a specific index by root page number.
    def index(root_page_number, record_describer = nil)
      Innodb::Index.new(self, root_page_number, record_describer || @record_describer)
    end

    # Iterate through all root page numbers for indexes in the space.
    def each_index_root_page_number
      return enum_for(:each_index_root_page_number) unless block_given?

      if innodb_system&.data_dictionary&.populated?
        # Retrieve the index root page numbers from the data dictionary.
        # TODO: An efficient way to handle this?
        tablespace = innodb_system.data_dictionary.tablespaces.find(innodb_space_id: space_id)
        innodb_system.data_dictionary.tables.each do |table|
          table.indexes.by(tablespace: tablespace).each do |index|
            yield index.root_page_number
          end
        end
      else
        # Guess that the index root pages will be present starting at page 3,
        # and walk forward until we find a non-root page. This should work fine
        # for IBD files, if they haven't added indexes online.
        (3...@pages).each do |page_number|
          page = page(page_number)
          yield page_number if page.is_a?(Innodb::Page::Index) && page.root?
        end
      end

      nil
    end

    # Iterate through all indexes in the space.
    def each_index
      return enum_for(:each_index) unless block_given?

      each_index_root_page_number do |page_number|
        yield index(page_number)
      end

      nil
    end

    # Iterate through Innodb::Inode lists in the space.
    def each_inode_list
      return enum_for(:each_inode_list) unless block_given?

      INODE_LISTS.each do |name|
        yield name, list(name)
      end
    end

    # Iterate through Innodb::Inode objects in the space.
    def each_inode(&block)
      return enum_for(:each_inode) unless block_given?

      each_inode_list do |_name, list|
        list.each do |page|
          page.each_allocated_inode(&block)
        end
      end
    end

    # Return an Inode by fseg_id. Iterates through the inode list, but it
    # normally is fairly small, so should be relatively efficient.
    def inode(fseg_id)
      each_inode.select { |inode| inode.fseg_id == fseg_id }.first
    end

    # Iterate through the page numbers in the doublewrite buffer.
    def each_doublewrite_page_number(&block)
      return nil unless system_space?
      return enum_for(:each_doublewrite_page_number) unless block_given?

      trx_sys.doublewrite[:page_info][0][:page_number].each do |start_page|
        (start_page...(start_page + pages_per_extent)).each(&block)
      end
    end

    # Return true if a page is in the doublewrite buffer.
    def doublewrite_page?(page_number)
      return false unless system_space?

      @doublewrite_pages ||= each_doublewrite_page_number.to_a
      @doublewrite_pages.include?(page_number)
    end

    # Iterate through all pages in a space, returning the page number and an
    # Innodb::Page object for each one.
    def each_page(start_page = 0)
      return enum_for(:each_page, start_page) unless block_given?

      (start_page...@pages).each do |page_number|
        current_page = page(page_number)
        yield page_number, current_page if current_page
      end
    end

    # Iterate through Innodb::Xdes lists in the space.
    def each_xdes_list
      return enum_for(:each_xdes_list) unless block_given?

      XDES_LISTS.each do |name|
        yield name, list(name)
      end
    end

    # An array of all FSP/XDES page numbers for the space.
    def each_xdes_page_number(&block)
      return enum_for(:each_xdes_page_number) unless block_given?

      0.step(pages - 1, pages_per_bookkeeping_page).each(&block)
    end

    # Iterate through all extent descriptor pages, returning an Innodb::Page object
    # for each one.
    def each_xdes_page
      return enum_for(:each_xdes_page) unless block_given?

      each_xdes_page_number do |page_number|
        current_page = page(page_number)
        yield current_page if current_page&.extent_descriptor?
      end
    end

    # Iterate through all extent descriptors for the space, returning an
    # Innodb::Xdes object for each one.
    def each_xdes
      return enum_for(:each_xdes) unless block_given?

      each_xdes_page do |xdes_page|
        xdes_page.each_xdes do |xdes|
          # Only return initialized XDES entries; :state will be nil for extents
          # that have not been allocated yet.
          yield xdes if xdes.xdes[:state]
        end
      end
    end

    # Iterate through all pages, yielding the page number, page object,
    # and page status.
    def each_page_status(start_page = 0)
      return enum_for(:each_page_status, start_page) unless block_given?

      each_xdes do |xdes|
        xdes.each_page_status do |page_number, page_status|
          next if page_number < start_page
          next if page_number >= @pages

          if (this_page = page(page_number))
            yield page_number, this_page, page_status
          end
        end
      end
    end

    # A helper to produce a printable page type.
    def type_for_page(page, page_status)
      page_status[:free] ? "FREE (#{page.type})" : page.type
    end

    # Iterate through unique regions in the space by page type. This is useful
    # to achieve an overall view of the space.
    def each_page_type_region(start_page = 0)
      return enum_for(:each_page_type_region, start_page) unless block_given?

      region = nil
      each_page_status(start_page) do |page_number, page, page_status|
        page_type = type_for_page(page, page_status)
        if region && region[:type] == page_type
          region[:end] = page_number
          region[:count] += 1
        else
          yield region if region
          region = {
            start: page_number,
            end: page_number,
            type: page_type,
            count: 1,
          }
        end
      end
      yield region if region
    end
  end
end
