local setmetatable = setmetatable
local print, pairs = print, pairs
local ipairs, type = ipairs, type
local string, unpack = string, unpack or table.unpack
local awful = require("awful")

local capi, sn_callback =
	{ client = client, tag = tag, awesome = awesome, screen = screen, mouse = mouse }, awful.spawn.snid_buffer or {}

-------------------------------INIT------------------------------

local module, c_rules, tags_hash, settings, fallbacks =
	{}, { class = {}, instance = {} }, {}, { tag = {}, client = {} }, {}

----------------------TYRANNICAL LOGIC--------------------------

--Called when a tag is selected/unselected
local function on_selected_change(tag, data)
	if data and data.exec_once and tag.selected then
		for _, v in ipairs(type(data.exec_once) == "string" and { data.exec_once } or data.exec_once) do
			awful.spawn.single_instance(v)
		end
	end
end

local function get_class(c)
	return awful.client.property.get(c, "overwrite_class") or c.class or "N/A"
end

local function get_screen_idx(s)
	return type(s) == "number" and s or s.index
end

local function scr_exists(s)
	local t = type(s)
	return (t == "number" and s > 0 and s <= capi.screen.count()) or t == "screen" or (t == "table" and s.workarea)
end

--Load tags, this cannot be undone
local function load_tags(tyrannical_tags)
	for k, v in ipairs(tyrannical_tags) do
		if v.init ~= false then
			if type(v.screen) == "table" then
				local screens = v.screen --TODO remove
				for k2, v2 in pairs(screens) do
					if (type(v2) == "number" and v2 or v2.index) <= capi.screen.count() then
						v.screen = v2 --TODO remove
						awful.tag.add(v.name, v, { screen = v2 }).is_template = true
					end
				end
				v.screen = screens --TODO remove
			elseif
				(v.screen and (type(v.screen) == "number" and v.screen or v.screen.index) or 1) <= capi.screen.count()
			then
				awful.tag.add(v.name, v).is_template = true
			end
		elseif v.volatile == nil then
			v.volatile = true
		end
		for _, prop in ipairs({ "class", "instance" }) do
			if v[prop] and c_rules[prop] then
				--                 for low in (function() local i=0; return function() i=i+1; return prop[i] and prop[i]:lower() end end)() do --TODO fix
				for i = 1, #v[prop] do
					local low = string.lower(v[prop][i])
					local tmp = c_rules[prop][low] or { tags = {}, properties = {} }
					tmp.tags[#tmp.tags + 1] = awful.util.table.hasitem(tmp.tags, v) == nil and v or nil --Avoid duplicates
					c_rules[prop][low] = tmp
				end
			end
		end
		tags_hash[v.name or "N/A"] = v
	end
end

--Load property
local function load_property(name, property)
	for k2, v2 in pairs(property) do --TODO make an iterator?
		local key_type = type(k2)
		local low = string.lower(key_type == "number" and v2 or k2)
		c_rules.class[low] = c_rules.class[low] or { name = low, tags = {}, properties = {} }
		c_rules.class[low].properties[name] = key_type == "number" and true or v2
	end
end

local function has_selected(tags, screen)
	if #tags == 0 then
		return false
	end

	for _, t in ipairs(screen.selected_tags) do
		if awful.util.table.hasitem(tags, t) then
			return true
		end
	end

	return false
end

function awful.rules.delayed_properties.master(c, value)
	if not value then
		return
	end

	awful.client.setmaster(c)

	-- Some smarter layouts may implement this directly
	c.master = true
end

function awful.rules.delayed_properties.slave(c, value)
	if not value then
		return
	end

	awful.client.setslave(c)

	-- Some smarter layouts may implement this directly
	c.slave = true
end

function module.focus_client(c, properties)
	if
		((not c.transient_for) or (c.transient_for == capi.client.focus) or not settings.block_children_focus_stealing)
		and not c.no_autofocus
	then
		local tags = c:tags()

		if #tags > 0 and not has_selected(tags, c.screen) and not tags[1].no_focus_stealing_in then
			local t = c:tags()[1]
			if t.no_tag_deselect then
				t.selected = true
			else
				t:view_only()
			end
		end

		capi.client.focus = c
		c:raise()
		return true
	end
end

--Apply all properties
local function apply_properties(c, props, callbacks)
	local force_intrusive = settings.force_odd_as_intrusive and c.type ~= "normal"

	local is_intrusive = force_intrusive
		or type(props.intrusive) == "function" and props.intrusive(c)
		or props.intrusive

	if props.tag or props.tags or props.new_tag then
		is_intrusive = false
	end

	local has_tag = props.tag or props.new_tag or props.tags
	--Check if the client should be added to an existing tag (or tags)
	if (not has_tag) and is_intrusive then
		local tag = capi.mouse.screen.selected_tag or capi.mouse.screen.tags[1]

		if tag and not tag.selected then
			tag:view_only()
		end

		if tag then --Can be false if there is no tags
			props.tag, props.tags, props.intrusive = tag, nil, false
		end
	end

	awful.rules.execute(c, props, callbacks)
end

local function select_screen(tag)
	local s

	-- If there is a table of screen, check if it contains the mouse one
	if type(tag.screen) == "table" and tag.screen[1] then
		for k, ss in ipairs(tag.screen) do
			ss = type(ss) == "number" and ss <= capi.screen.count() and ss or nil
			if ss and capi.screen[ss] == awful.screen.focused() then
				s = ss
			end
		end
		s = s or capi.screen[tag.screen[1]]
	else
		s = scr_exists(tag.screen) and capi.screen[tag.screen] or nil
	end

	-- If the tag.force_screen is set, then obey
	if (tag.force_screen and s) or (s and settings.favor_focused == false) then
		return s
	end

	-- By default, Tyrannical prefer to use the focused screen to place new tags
	-- This override some other settings, but is more pleasant to use.
	return awful.screen.focused()
end

--Match client
local function match_client(c, forced_tags, hints)
	-- Don't prevent tags from being drag and dropped between screens
	if hints and hints.reason == "screen" then
		c:tags({ c.screen.selected_tag })
		return true
	end

	if (not c) or #c:tags() > 0 then
		return
	end

	local props = c.startup_id and sn_callback[tostring(c.startup_id)] or {}

	local low_i = string.lower(c.instance or "N/A")
	local low_c = string.lower(get_class(c))
	local tags = props.tags or { props.tag }

	local rules = c_rules.instance[low_i] or c_rules.class[low_c]

	if #tags == 0 and c.transient_for and (capi.mouse.screen or (rules and rules.properties.intrusive_popup)) then
		c.sticky = c.transient_for.sticky or false
		c:tags(
			awful.util.table.join(
				c.transient_for:tags(),
				(rules and rules.properties.intrusive_popup) and c.screen.selected_tags
			)
		)
		return module.focus_client(c, props)
	elseif forced_tags then
		return module.focus_client(c, props)
	elseif rules then
		--Add to matches
		local tags_src, fav_scr, c_src, mouse_s = {}, false, c.screen, capi.mouse.screen
		for j = 1, #(#tags == 0 and rules.tags or {}) do
			local tag, cache = rules.tags[j], rules.tags[j].screen
			tag.instances = tag.instances or setmetatable({}, { __mode = "v" })

			tag.screen = select_screen(tag)

			match = tag.instances[get_screen_idx(tag.screen)]
			tag.screen = tag.screen and get_screen_idx(tag.screen) or nil
			local max_clients = match
					and (type(match.max_clients) == "function" and match.max_clients(c, match) or match.max_clients)
				or 999
			if (not match and not (fav_scr == true and mouse_s ~= tag.screen)) or (max_clients <= #match:clients()) then
				local t = awful.tag.add(tag.name, tag)
				t.volatile = match and (max_clients ~= nil) or tag.volatile
				t.is_template = true
			end
			tags_src[tag.screen], fav_scr = tags_src[tag.screen] or {}, fav_scr or (tag.screen == mouse_s) --Reset if a better screen is found
			tags_src[tag.screen][#tags_src[get_screen_idx(tag.screen)] + 1] = tag.instances[get_screen_idx(tag.screen)]
			tag.screen = cache
		end
		for k, t in
			ipairs(
				tags_src[mouse_s]
					or tags_src[c_src]
					or select(2, next(tags_src))
					or awful.util.table.join(unpack(tags_src))
			)
		do
			tags[#tags + 1] = t.locked ~= true and t.activated and t or nil --Do not add to locked tags
		end
		c.screen = tags[1] and tags[1].screen or c_src
		if #tags > 0 then
			c:tags(tags)
			return module.focus_client(c, props)
		end
	end

	--Add to the current tag if not exclusive
	local cur_tag = c.screen.selected_tag
	if cur_tag and cur_tag.exclusive ~= true and cur_tag.locked ~= true then
		c:tags({ cur_tag })
		return module.focus_client(c, props)
	end

	--Add to the fallback tags
	if
		#c:tags((function(arr)
			for k, v in ipairs(fallbacks) do
				arr[#arr + 1] = v.screen == c.screen and v or nil
			end
			return arr
		end)({})) > 0
	then -- Select the first fallback tag if the current tag isn't a fallback
		return module.focus_client(c, props)
	end
	--Last resort, create a new tag
	c_rules.class[low_c] = c_rules.class[low_c] or { tags = {}, properties = {} }
	local tmp, tag =
		c_rules.class[low_c],
		awful.tag.add(
			get_class(c),
			{
				name = get_class(c),
				onetimer = true,
				volatile = true,
				exclusive = true,
				screen = (c.screen.index <= capi.screen.count()) and c.screen or capi.screen.primary or capi.screen[1],
				layout = settings.tag.layout or settings.default_layout or awful.layout.suit.max,
			}
		)
	tmp.tags[#tmp.tags + 1] = {
		name = get_class(c),
		instances = setmetatable({ [get_screen_idx(c.screen)] = tag }, { __mode = "v" }),
		volatile = true,
		screen = c.screen,
		exclusive = true,
	}
	c:tags({ tag })
	return module.focus_client(c, props)
end

capi.client.disconnect_signal("request::tag", awful.ewmh.tag)
capi.client.connect_signal("request::tag", match_client)

capi.client.connect_signal("untagged", function(c, t)
	if t.volatile == true and #t:clients() == 0 then
		local rules = c_rules.class[string.lower(get_class(c))]
		c_rules.class[string.lower(get_class(c))] = (t.onetimer ~= true or c.class == nil) and rules or nil --Prevent "last resort tags" from persisting
		for j = 1, #(rules and rules.tags or {}) do
			rules.tags[j].instances[get_screen_idx(c.screen)] = rules.tags[j].instances[get_screen_idx(c.screen)] ~= t
					and rules.tags[j].instances[get_screen_idx(c.screen)]
				or nil
		end
		--         awful.tag.delete(t)
	end
end)

awful.tag._add = awful.tag.add

awful.tag.add = function(tag, props, override)
	props.screen, props.instances =
		props.screen or capi.mouse.screen, props.instances or setmetatable({}, { __mode = "v" })
	props.master_width_factor, props.layout =
		props.master_width_factor or settings.tag.master_width_factor or settings.master_width_factor,
		props.layout or settings.default_layout or awful.layout.max
	local t = awful.tag._add(tag, awful.util.table.join(settings.tag, props, override))
	fallbacks[#fallbacks + 1] = props.fallback and t or nil
	t:connect_signal("property::selected", function(t)
		on_selected_change(t, props or {})
	end)
	t.selected = props.selected or false
	props.instances[get_screen_idx(props.screen)] = t
	return t
end

capi.tag.connect_signal("property::fallback", function(t)
	fallbacks[awful.util.table.hasitem(fallbacks, t) or (#fallbacks + 1)] = t.fallback and t or nil
end)

local function contain_screen(tab, s)
	for _, v in ipairs(tab) do
		if type(v) ~= "number" or v <= capi.screen.count() then
			v = capi.screen[v]
			if v == s then
				return true
			end
		end
	end

	return false
end

-- Add init tags to newly connected screens
awful.screen.connect_for_each_screen(function(s)
	for _, def in pairs(tags_hash) do
		if def.init then
			if
				def.screen and (type(def.screen) == "table" and contain_screen(def.screen, s))
				or (type(def.screen) == "number" and def.screen <= capi.screen.count())
				or (type(def.screen) == "screen")
			then
				local real_s = def.screen
				def.screen = s
				awful.tag.add(def.name, def).is_template = true
				def.screen = real_s
			end
		end
	end

	-- Restore old tags to their original screen
	for ss in capi.screen do
		for _, t in ipairs(ss.tags) do
			if t.saved_from == s.index then
				t.screen = s
			end
		end
	end
end)

-- Handle events such as screen being removed
capi.tag.connect_signal("request::screen", function(t)
	-- Only salvage used tags
	if #t:clients() > 0 then
		-- If the same class of tag exist on another screen, use that
		if t.is_template and t.instances then
			local new_tag
			for k, t in pairs(t.instances) do
				if k ~= t.screen.index and k <= capi.screen.count() then
					new_tag = capi.screen[k]
					break
				end
			end

			if new_tag then
				for _, c in ipairs(t:clients()) do
					c:tags({ new_tag }) --TODO batch this to if a client is on 2 tags, it doesn't get removed from the other
				end

				-- The tag will be deleted by awful.tag
				return
			end
		end

		local new_screen = capi.screen.primary or awful.screen.focused() --TODO be smarter

		-- In case the screen comes back, save the old index
		t.saved_from = t.screen.index

		-- Move the tag to an existing screen
		t.selected = false
		t.screen = new_screen
	end
end)

local function apply_tyrannical_rules(c, props, callbacks)
	-- In case the class is overwriten
	local low_c = props.overwrite_class or string.lower(get_class(c))
	local low_i = string.lower(c.instance or "N/A")

	-- Add Tyrannical properties
	local props_src = (c_rules.instance[low_i] or c_rules.class[low_c] or {}).properties or {}

	awful.util.table.crush(props, props_src)
end

awful.rules.add_rule_source("tyrannical", apply_tyrannical_rules, { "snid", "awful.spawn" }, { "awful.rules" })

capi.client.disconnect_signal("request::activate", awful.ewmh.activate)
capi.client.connect_signal("request::activate", function(c, reason)
	-- Always grant those request as it probably mean that it is a modal dialog
	if c.transient_for and capi.client.focus == c.transient_for then
		capi.client.focus = c
		c:raise()
	-- If it is not modal, then use the normal code path
	elseif reason == "rule" or reason == "rules" or reason == "ewmh" then
		module.focus_client(c)
	-- Tyrannical doesn't have enough information, grant the request
	else
		capi.client.focus = c
		c:raise()
	end
end)

--------------------------OBJECT GEARS---------------------------
local getter = {
	properties = setmetatable({}, {
		__newindex = function(table, k, v)
			load_property(k, v)
		end,
	}),
	settings = settings,
	tags_by_name = tags_hash,
	sn_callback = sn_callback,
}
local setter = { tags = load_tags }

return setmetatable(module, {
	__index = function(t, k)
		return getter[k]
	end,
	__newindex = function(t, k, v)
		if setter[k] then
			return setter[k](v)
		end
	end,
})
