require "image_size"
require "yaml"

abstract class Entry
  getter id : String, book : Title, title : String,
    size : String, pages : Int32, mtime : Time,
    encoded_path : String, encoded_title : String, err_msg : String?

  def initialize(
    @id, @title, @book,
    @size, @pages, @mtime,
    @encoded_path, @encoded_title, @err_msg
  )
  end

  def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
    # TODO: check node? and select proper subclass
    ZippedEntry.new ctx, node
  rescue e
    DirectoryEntry.new ctx, node
  end

  def build_json(*, slim = false)
    JSON.build do |json|
      json.object do
        {% for str in %w(path title size id) %}
        json.field {{str}}, {{str.id}}
      {% end %}
        if err_msg
          json.field "err_msg", err_msg
        end
        json.field "title_id", @book.id
        json.field "title_title", @book.title
        json.field "sort_title", sort_title
        json.field "pages" { json.number @pages }
        unless slim
          json.field "display_name", @book.display_name @title
          json.field "cover_url", cover_url
          json.field "mtime" { json.number @mtime.to_unix }
        end
      end
    end
  end

  @[YAML::Field(ignore: true)]
  @sort_title : String?

  def sort_title
    sort_title_cached = @sort_title
    return sort_title_cached if sort_title_cached
    sort_title = @book.entry_sort_title_db id
    if sort_title
      @sort_title = sort_title
      return sort_title
    end
    @sort_title = @title
    @title
  end

  def set_sort_title(sort_title : String | Nil, username : String)
    Storage.default.set_entry_sort_title id, sort_title
    if sort_title == "" || sort_title.nil?
      @sort_title = nil
    else
      @sort_title = sort_title
    end

    @book.entry_sort_title_cache = nil
    @book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title],
      username
  end

  def sort_title_db
    @book.entry_sort_title_db @id
  end

  def display_name
    @book.display_name @title
  end

  def encoded_display_name
    URI.encode display_name
  end

  def cover_url
    return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg

    unless @book.entry_cover_url_cache
      TitleInfo.new @book.dir do |info|
        @book.entry_cover_url_cache = info.entry_cover_url
      end
    end
    entry_cover_url = @book.entry_cover_url_cache

    url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
    if entry_cover_url
      info_url = entry_cover_url[@title]?
      unless info_url.nil? || info_url.empty?
        url = File.join Config.current.base_url, info_url
      end
    end
    url
  end

  def next_entry(username)
    entries = @book.sorted_entries username
    idx = entries.index self
    return nil if idx.nil? || idx == entries.size - 1
    entries[idx + 1]
  end

  def previous_entry(username)
    entries = @book.sorted_entries username
    idx = entries.index self
    return nil if idx.nil? || idx == 0
    entries[idx - 1]
  end

  # For backward backward compatibility with v0.1.0, we save entry titles
  #   instead of IDs in info.json
  def save_progress(username, page)
    LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
    @book.parents.each do |parent|
      LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
    end
    @book.remove_sorted_caches [SortMethod::Progress], username

    TitleInfo.new @book.dir do |info|
      if info.progress[username]?.nil?
        info.progress[username] = {@title => page}
      else
        info.progress[username][@title] = page
      end
      # save last_read timestamp
      if info.last_read[username]?.nil?
        info.last_read[username] = {@title => Time.utc}
      else
        info.last_read[username][@title] = Time.utc
      end
      info.save
    end
  end

  def load_progress(username)
    progress = 0
    TitleInfo.new @book.dir do |info|
      unless info.progress[username]?.nil? ||
             info.progress[username][@title]?.nil?
        progress = info.progress[username][@title]
      end
    end
    [progress, @pages].min
  end

  def load_percentage(username)
    page = load_progress username
    page / @pages
  end

  def load_last_read(username)
    last_read = nil
    TitleInfo.new @book.dir do |info|
      unless info.last_read[username]?.nil? ||
             info.last_read[username][@title]?.nil?
        last_read = info.last_read[username][@title]
      end
    end
    last_read
  end

  def finished?(username)
    load_progress(username) == @pages
  end

  def started?(username)
    load_progress(username) > 0
  end

  def generate_thumbnail : Image?
    return if @err_msg

    img = read_page(1).not_nil!
    begin
      size = ImageSize.get img.data
      if size.height > size.width
        thumbnail = ImageSize.resize img.data, width: 200
      else
        thumbnail = ImageSize.resize img.data, height: 300
      end
      img.data = thumbnail
      img.size = thumbnail.size
      unless img.mime == "image/webp"
        # image_size.cr resizes non-webp images to jpg
        img.mime = "image/jpeg"
      end
      Storage.default.save_thumbnail @id, img
    rescue e
      Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
    end

    img
  end

  def get_thumbnail : Image?
    Storage.default.get_thumbnail @id
  end

  def date_added : Time
    date_added = nil
    TitleInfo.new @book.dir do |info|
      info_da = info.date_added[@title]?
      if info_da.nil?
        date_added = info.date_added[@title] = createtime
        info.save
      else
        date_added = info_da
      end
    end
    date_added.not_nil! # is it ok to set not_nil! here?
  end

  abstract def path : String

  abstract def createtime : Time

  abstract def read_page(page_num)

  abstract def page_dimensions

  abstract def examine : Bool?
end

class ZippedEntry < Entry
  include YAML::Serializable

  getter zip_path : String

  def initialize(@zip_path, @book)
    storage = Storage.default
    @encoded_path = URI.encode @zip_path
    @title = File.basename @zip_path, File.extname @zip_path
    @encoded_title = URI.encode @title
    @size = (File.size @zip_path).humanize_bytes
    id = storage.get_entry_id @zip_path, File.signature(@zip_path)
    if id.nil?
      id = random_str
      storage.insert_entry_id({
        path:      @zip_path,
        id:        id,
        signature: File.signature(@zip_path).to_s,
      })
    end
    @id = id
    @mtime = File.info(@zip_path).modification_time

    unless File.readable? @zip_path
      @err_msg = "File #{@zip_path} is not readable."
      Logger.warn "#{@err_msg} Please make sure the " \
                  "file permission is configured correctly."
      return
    end

    archive_exception = validate_archive @zip_path
    unless archive_exception.nil?
      @err_msg = "Archive error: #{archive_exception}"
      Logger.warn "Unable to extract archive #{@zip_path}. " \
                  "Ignoring it. #{@err_msg}"
      return
    end

    file = ArchiveFile.new @zip_path
    @pages = file.entries.count do |e|
      SUPPORTED_IMG_TYPES.includes? \
        MIME.from_filename? e.filename
    end
    file.close
  end

  def path : String
    @zip_path
  end

  def createtime : Time
    ctime @zip_path
  end

  private def sorted_archive_entries
    ArchiveFile.open @zip_path do |file|
      entries = file.entries
        .select { |e|
          SUPPORTED_IMG_TYPES.includes? \
            MIME.from_filename? e.filename
        }
        .sort! { |a, b|
          compare_numerically a.filename, b.filename
        }
      yield file, entries
    end
  end

  def read_page(page_num)
    raise "Unreadble archive. #{@err_msg}" if @err_msg
    img = nil
    begin
      sorted_archive_entries do |file, entries|
        page = entries[page_num - 1]
        data = file.read_entry page
        if data
          img = Image.new data, MIME.from_filename(page.filename),
            page.filename, data.size
        end
      end
    rescue e
      Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
    end
    img
  end

  def page_dimensions
    sizes = [] of Hash(String, Int32)
    sorted_archive_entries do |file, entries|
      entries.each_with_index do |e, i|
        begin
          data = file.read_entry(e).not_nil!
          size = ImageSize.get data
          sizes << {
            "width"  => size.width,
            "height" => size.height,
          }
        rescue e
          Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
          sizes << {"width" => 1000_i32, "height" => 1000_i32}
        end
      end
    end
    sizes
  end

  def examine : Bool
    File.exists? @zip_path
  end
end

class DirectoryEntry < Entry
  include YAML::Serializable

  getter dir_path : String

  @[YAML::Field(ignore: true)]
  @sorted_files : Array(String)?

  @signature : String

  def initialize(@dir_path, @book)
    storage = Storage.default
    @encoded_path = URI.encode @dir_path
    @title = File.basename @dir_path
    @encoded_title = URI.encode @title

    unless File.readable? @dir_path
      @err_msg = "Directory #{@dir_path} is not readable."
      Logger.warn "#{@err_msg} Please make sure the " \
                  "file permission is configured correctly."
      return
    end

    unless DirectoryEntry.validate_directory_entry @dir_path
      @err_msg = "Directory #{@dir_path} is not valid directory entry."
      Logger.warn "#{@err_msg} Please make sure the " \
                  "directory has valid images."
      return
    end

    size_sum = 0
    sorted_files.each do |file_path|
      size_sum += File.size file_path
    end
    @size = size_sum.humanize_bytes

    @signature = Dir.directory_entry_signature @dir_path
    id = storage.get_entry_id @dir_path, @signature
    if id.nil?
      id = random_str
      storage.insert_entry_id({
        path:      @dir_path,
        id:        id,
        signature: @signature,
      })
    end
    @id = id

    @mtime = sorted_files.map do |file_path|
      File.info(file_path).modification_time
    end.max
    @pages = sorted_files.size
  end

  def path : String
    @dir_path
  end

  def createtime : Time
    ctime @dir_path
  end

  def read_page(page_num)
    img = nil
    begin
      files = sorted_files
      file_path = files[page_num - 1]
      data = File.read(file_path).to_slice
      if data
        img = Image.new data, MIME.from_filename(file_path),
          File.basename(file_path), data.size
      end
    rescue e
      Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
    end
    img
  end

  def page_dimensions
    sizes = [] of Hash(String, Int32)
    sorted_files.each_with_index do |path, i|
      data = File.read(path).to_slice
      begin
        data.not_nil!
        size = ImageSize.get data
        sizes << {
          "width"  => size.width,
          "height" => size.height,
        }
      rescue e
        Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
        sizes << {"width" => 1000_i32, "height" => 1000_i32}
      end
    end
    sizes
  end

  def examine : Bool
    existence = File.exists? @dir_path
    return false unless existence
    files = DirectoryEntry.get_valid_files @dir_path
    signature = Dir.directory_entry_signature @dir_path
    existence = files.size > 0 && @signature == signature
    @sorted_files = nil unless existence

    # For more efficient, update a directory entry with new property
    # and return true like Title.examine
    existence
  end

  def sorted_files
    cached_sorted_files = @sorted_files
    return cached_sorted_files if cached_sorted_files
    @sorted_files = DirectoryEntry.get_valid_files_sorted @dir_path
    @sorted_files.not_nil!
  end

  def self.validate_directory_entry(dir_path)
    files = DirectoryEntry.get_valid_files dir_path
    files.size > 0
  end

  def self.get_valid_files(dir_path)
    files = [] of String
    Dir.entries(dir_path).each do |fn|
      next if fn.starts_with? "."
      path = File.join dir_path, fn
      next unless is_supported_image_file path
      next if File.directory? path
      next unless File.readable? path
      files << path
    end
    files
  end

  def self.get_valid_files_sorted(dir_path)
    files = DirectoryEntry.get_valid_files dir_path
    files.sort! { |a, b| compare_numerically a, b }
  end
end
