require "zip"
require "mime"
require "json"
require "uri"
require "./util"

struct Image
	property data : Bytes
	property mime : String
	property filename : String
	property size : Int32

	def initialize(@data, @mime, @filename, @size)
	end
end

class Entry
	property zip_path : String, book : Title, title : String,
		size : String, pages : Int32, cover_url : String, id : String,
		title_id : String, encoded_path : String, encoded_title : String,
		mtime : Time

	def initialize(path, @book, @title_id, storage)
		@zip_path = path
		@encoded_path = URI.encode path
		@title = File.basename path, File.extname path
		@encoded_title = URI.encode @title
		@size = (File.size path).humanize_bytes
		file = Zip::File.new path
		@pages = file.entries
			.select { |e|
				["image/jpeg", "image/png"].includes? \
				MIME.from_filename? e.filename
			}
			.size
		file.close
		@id = storage.get_id @zip_path, false
		@cover_url = "/api/page/#{@title_id}/#{@id}/1"
		@mtime = File.info(@zip_path).modification_time
	end

	def to_json(json : JSON::Builder)
		json.object do
			{% for str in ["zip_path", "title", "size", "cover_url", "id",
					"title_id", "encoded_path", "encoded_title"] %}
				json.field {{str}}, @{{str.id}}
			{% end %}
			json.field "display_name", @book.display_name @title
			json.field "pages" {json.number @pages}
			json.field "mtime" {json.number @mtime.to_unix}
		end
	end

	def display_name
		@book.display_name @title
	end

	def encoded_display_name
		URI.encode display_name
	end

	def read_page(page_num)
		Zip::File.open @zip_path do |file|
			page = file.entries
				.select { |e|
					["image/jpeg", "image/png"].includes? \
					MIME.from_filename? e.filename
				}
				.sort { |a, b|
					compare_alphanumerically a.filename, b.filename
				}
				.[page_num - 1]
			page.open do |io|
				slice = Bytes.new page.uncompressed_size
				bytes_read = io.read_fully? slice
				unless bytes_read
					return nil
				end
				return Image.new slice, MIME.from_filename(page.filename),\
					page.filename, bytes_read
			end
		end
	end
end

class Title
	property dir : String, parent_id : String, title_ids : Array(String),
		entries : Array(Entry), title : String, id : String,
		encoded_title : String, mtime : Time

	def initialize(@dir : String, @parent_id, storage,
				   @logger : MLogger, @library : Library)
		@id = storage.get_id @dir, true
		@title = File.basename dir
		@encoded_title = URI.encode @title
		@title_ids = [] of String
		@entries = [] of Entry
		@mtime = File.info(dir).modification_time

		Dir.entries(dir).each do |fn|
			next if fn.starts_with? "."
			path = File.join dir, fn
			if File.directory? path
				title = Title.new path, @id, storage, @logger, library
				next if title.entries.size == 0 && title.titles.size == 0
				@library.title_hash[title.id] = title
				@title_ids << title.id
				next
			end
			if [".zip", ".cbz"].includes? File.extname path
				next if !valid_zip path
				entry = Entry.new path, self, @id, storage
				@entries << entry if entry.pages > 0
			end
		end

		mtimes = [@mtime]
		mtimes += @title_ids.map{|e| @library.title_hash[e].mtime}
		mtimes += @entries.map{|e| e.mtime}
		@mtime = mtimes.max

		@title_ids.sort! do |a, b|
			compare_alphanumerically @library.title_hash[a].title,
				@library.title_hash[b].title
		end
		@entries.sort! do |a, b|
			compare_alphanumerically a.title, b.title
		end
	end

	def to_json(json : JSON::Builder)
		json.object do
			{% for str in ["dir", "title", "id", "encoded_title"] %}
				json.field {{str}}, @{{str.id}}
			{% end %}
			json.field "display_name", display_name
			json.field "mtime" {json.number @mtime.to_unix}
			json.field "titles" do
				json.raw self.titles.to_json
			end
			json.field "entries" do
				json.raw @entries.to_json
			end
			json.field "parents" do
				json.array do
					self.parents.each do |title|
						json.object do
							json.field "title", title.title
							json.field "id", title.id
						end
					end
				end
			end
		end
	end

	def titles
		@title_ids.map {|tid| @library.get_title! tid}
	end

	def parents
		ary = [] of Title
		tid = @parent_id
		while !tid.empty?
			title = @library.get_title! tid
			ary << title
			tid = title.parent_id
		end
		ary
	end

	def size
		@entries.size + @title_ids.size
	end

	# When downloading from MangaDex, the zip/cbz file would not be valid
	#	before the download is completed. If we scan the zip file,
	#	Entry.new would throw, so we use this method to check before
	#	constructing Entry
	private def valid_zip(path : String)
		begin
			file = Zip::File.new path
			file.close
			return true
		rescue
			@logger.warn "File #{path} is corrupted or is not a valid zip "\
				"archive. Ignoring it."
			return false
		end
	end

	def get_entry(eid)
		@entries.find { |e| e.id == eid }
	end

	def display_name
		info = TitleInfo.new @dir
		dn = info.display_name
		dn.empty? ? @title : dn
	end

	def encoded_display_name
		URI.encode display_name
	end

	def display_name(entry_name)
		info = TitleInfo.new @dir
		dn = info.entry_display_name[entry_name]?
		unless dn.nil? || dn.empty?
			return dn
		end
		entry_name
	end

	def set_display_name(dn)
		info = TitleInfo.new @dir
		info.display_name = dn
		info.save
	end

	def set_display_name(entry_name : String, dn)
		info = TitleInfo.new @dir
		info.entry_display_name[entry_name] = dn
		info.save
	end

	# For backward backward compatibility with v0.1.0, we save entry titles
	#	instead of IDs in info.json
	def save_progress(username, entry, page)
		info = TitleInfo.new @dir
		if info.progress[username]?.nil?
			info.progress[username] = {entry => page}
			info.save
			return
		end
		info.progress[username][entry] = page
		info.save
	end

	def load_progress(username, entry)
		info = TitleInfo.new @dir
		if info.progress[username]?.nil?
			return 0
		end
		if info.progress[username][entry]?.nil?
			return 0
		end
		info.progress[username][entry]
	end

	def load_percetage(username, entry)
		info = TitleInfo.new @dir
		page = load_progress username, entry
		entry_obj = @entries.find{|e| e.title == entry}
		return 0.0 if entry_obj.nil?
		page / entry_obj.pages
	end

	def load_percetage(username)
		return 0.0 if @entries.empty?
		read_pages = total_pages = 0
		@entries.each do |e|
			read_pages += load_progress username, e.title
			total_pages += e.pages
		end
		read_pages / total_pages
	end

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

class TitleInfo
	include JSON::Serializable

	property comment = "Generated by Mango. DO NOT EDIT!"
	# { user1: { entry1: 10, entry2: 0 } }
	property progress = {} of String => Hash(String, Int32)
	property display_name = ""
	# { entry1 : "display name" }
	property entry_display_name = {} of String => String

	@[JSON::Field(ignore: true)]
	property dir : String = ""

	def initialize(@dir)
		json_path = File.join @dir, "info.json"
		if File.exists? json_path
			info = TitleInfo.from_json File.read json_path
			@progress = info.progress.clone
			@display_name = info.display_name
			@entry_display_name = info.entry_display_name.clone
		end
	end

	def save
		json_path = File.join @dir, "info.json"
		File.write json_path, self.to_pretty_json
	end
end

class Library
	property dir : String, title_ids : Array(String), scan_interval : Int32,
		logger : MLogger, storage : Storage, title_hash : Hash(String, Title)

	def initialize(@dir, @scan_interval, @logger, @storage)
		# explicitly initialize @titles to bypass the compiler check. it will
		#	be filled with actual Titles in the `scan` call below
		@title_ids = [] of String
		@title_hash = {} of String => Title

		return scan if @scan_interval < 1
		spawn do
			loop do
				start = Time.local
				scan
				ms = (Time.local - start).total_milliseconds
				@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
				sleep @scan_interval * 60
			end
		end
	end
	def titles
		@title_ids.map {|tid| self.get_title!(tid) }
	end
	def to_json(json : JSON::Builder)
		json.object do
			json.field "dir", @dir
			json.field "titles" do
				json.raw self.titles.to_json
			end
		end
	end
	def get_title(tid)
		@title_hash[tid]?
	end
	def get_title!(tid)
		@title_hash[tid]
	end
	def scan
		unless Dir.exists? @dir
			@logger.info "The library directory #{@dir} does not exist. " \
				"Attempting to create it"
			Dir.mkdir_p @dir
		end
		@title_ids.clear
		(Dir.entries @dir)
			.select { |fn| !fn.starts_with? "." }
			.map { |fn| File.join @dir, fn }
			.select { |path| File.directory? path }
			.map { |path| Title.new path, "", @storage, @logger, self }
			.select { |title| !(title.entries.empty? && title.titles.empty?) }
			.sort { |a, b| a.title <=> b.title }
			.each do |title|
				@title_hash[title.id] = title
				@title_ids << title.id
			end
		@logger.debug "Scan completed"
	end
end
