# frozen_string_literal: true

require "ruby-bandwidth-iris"
Faraday.default_adapter = :em_synchrony

require "cbor"
require "countries"

require_relative "area_code_repo"
require_relative "form_template"

class TelSelections
	THIRTY_DAYS = 60 * 60 * 24 * 30

	def initialize(redis: REDIS, db: DB, memcache: MEMCACHE)
		@redis = redis
		@memcache = memcache
		@db = db
	end

	def set(jid, tel)
		@redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel.pending_value)
	end

	def set_tel(jid, tel)
		ChooseTel::Tn::LocalInventory.fetch(tel).then do |local_inv|
			set(
				jid,
				local_inv || ChooseTel::Tn::Bandwidth.new(ChooseTel::Tn.new(tel))
			)
		end
	end

	def delete(jid)
		@redis.del("pending_tel_for-#{jid}")
	end

	def [](jid)
		@redis.get("pending_tel_for-#{jid}").then do |tel|
			tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
		end
	end

	class HaveTel
		def initialize(tel)
			@tel = ChooseTel::Tn.for_pending_value(tel)
		end

		def choose_tel
			EMPromise.resolve(@tel)
		end
	end

	class ChooseTel
		class Fail < RuntimeError; end

		def initialize(db: DB, memcache: MEMCACHE)
			@db = db
			@memcache = memcache
		end

		def choose_tel(error: nil)
			Command.reply { |reply|
				reply.allowed_actions = [:next]
				reply.command << FormTemplate.render("tn_search", error: error)
			}.then do |iq|
				available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
				next available if available.is_a?(Tn::Bandwidth)

				choose_from_list(available.tns)
			rescue Fail
				choose_tel(error: $!.to_s)
			end
		end

		def choose_from_list(tns)
			raise Fail, "No numbers found, try another search." if tns.empty?

			Command.reply { |reply|
				reply.allowed_actions = [:next, :prev]
				reply.command << FormTemplate.render("tn_list", tns: tns)
			}.then { |iq|
				choose_from_list_result(tns, iq)
			}
		end

		def choose_from_list_result(tns, iq)
			tel = iq.form.field("tel")&.value
			return choose_tel if iq.prev? || !tel

			tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel))
		end

		class AvailableNumber
			def self.for(form, db: DB, memcache: MEMCACHE)
				qs = form.field("q")&.value.to_s.strip
				return Tn.for_pending_value(qs) if qs =~ /\A\+1\d{10}\Z/

				quantity = Quantity.for(form)
				q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)

				new(
					q.iris_query
					.merge(enableTNDetail: true, LCA: false),
					q.sql_query, quantity,
					fallback: q.fallback, memcache: memcache, db: db
				)
			end

			ACTION_FIELD = "http://jabber.org/protocol/commands#actions"

			def self.feelinglucky(q, form)
				return q unless q.empty?
				return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"

				"810"
			end

			def initialize(
				iris_query, sql_query, quantity,
				fallback: [], memcache: MEMCACHE, db: DB
			)
				@iris_query = iris_query.merge(quantity.iris_query)
				@sql_query = sql_query
				@quantity = quantity
				@fallback = fallback
				@memcache = memcache
				@db = db
			end

			def tns
				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
				unless (result = fetch_cache)
					result = fetch_bandwidth_inventory + fetch_local_inventory.sync
				end
				return next_fallback if result.empty? && !@fallback.empty?

				@quantity.limit(result)
			end

			def fetch_bandwidth_inventory
				BandwidthIris::AvailableNumber
					.list(@iris_query)
					.map { |tn| Tn::Bandwidth.new(Tn::Option.new(**tn)) }
			rescue BandwidthIris::APIError
				raise Fail, $!.message
			end

			def fetch_local_inventory
				return EMPromise.resolve([]) unless @sql_query

				@db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
					rows.map { |row|
						Tn::LocalInventory.new(Tn::Option.new(
							full_number: row["tel"].sub(/\A\+1/, ""),
							city: row["locality"],
							state: row["region"]
						), row["bandwidth_account_id"], price: row["premium_price"])
					}
				}
			end

			def next_fallback
				@memcache.set(cache_key, CBOR.encode([]), 43200)
				fallback = @fallback.shift
				self.class.new(
					fallback.iris_query.merge(enableTNDetail: true),
					fallback.sql_query,
					@quantity,
					fallback: @fallback,
					memcache: @memcache, db: @db
				).tns
			end

			def fetch_cache
				promise = EMPromise.new
				@memcache.get(cache_key, &promise.method(:fulfill))
				result = promise.sync
				result ? CBOR.decode(result) : nil
			end

			def cache_key
				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
			end

			class Quantity
				def self.for(form)
					return new(10) if form.field(ACTION_FIELD)&.value == "feelinglucky"

					rsm_max = form.find(
						"ns:set/ns:max",
						ns: "http://jabber.org/protocol/rsm"
					).first
					return new(rsm_max.content.to_i) if rsm_max

					new(10)
				end

				def initialize(quantity)
					@quantity = quantity
				end

				def limit(result)
					(result || [])[0..@quantity - 1]
				end

				def iris_query
					{ quantity: [@quantity, 500].min }
				end
			end
		end

		class Tn
			attr_reader :tel

			def self.for_pending_value(value)
				if value.start_with?("LocalInventory/")
					tel, account, price =
						value.sub(/\ALocalInventory\//, "").split("/", 3)
					LocalInventory.new(Tn.new(tel), account, price: price.to_d)
				else
					Bandwidth.new(Tn.new(value))
				end
			end

			def price
				0
			end

			# Creates and inserts transaction charging the customer
			# for the phone number. If price <= 0 this is a noop.
			# This method never checks customer balance.
			#
			# @param customer [Customer] the customer to charge
			def charge(customer)
				return if price <= 0

				transaction(customer).insert
			end

			# @param customer [Customer] the customer to charge
			def transaction(customer)
				Transaction.new(
					customer_id: customer.customer_id,
					transaction_id:
						"#{customer.customer_id}-bill-#{@tel}-at-#{Time.now.to_i}",
					amount: -price,
					note: "One-time charge for number: #{formatted_tel}",
					ignore_duplicate: false
				)
			end

			def initialize(tel)
				@tel = tel
			end

			def formatted_tel
				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
				"(#{$1}) #{$2}-#{$3}"
			end

			def to_s
				formatted_tel
			end

			class Option < Tn
				def initialize(full_number:, city:, state:, **)
					@tel = "+1#{full_number}"
					@locality = city
					@region = state
				end

				def option
					op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
					op << reference
					op
				end

				def reference
					Nokogiri::XML::Builder.new { |xml|
						xml.reference(
							xmlns: "urn:xmpp:reference:0",
							begin: 0,
							end: formatted_tel.length - 1,
							type: "data",
							uri: "tel:#{tel}"
						)
					}.doc.root
				end

				def to_s
					"#{formatted_tel} (#{@locality}, #{@region})"
				end
			end

			class Bandwidth < SimpleDelegator
				def pending_value
					tel
				end

				def reserve(customer)
					BandwidthTnReservationRepo.new.ensure(customer, tel)
				end

				def order(_, customer)
					BandwidthTnReservationRepo.new.get(customer, tel).then do |rid|
						BandwidthTNOrder.create(
							tel,
							customer_order_id: customer.customer_id,
							reservation_id: rid
						).then(&:poll)
					end
				end
			end

			class LocalInventory < SimpleDelegator
				attr_reader :price

				def self.fetch(tn, db: DB)
					db.query_defer("SELECT * FROM tel_inventory WHERE tel = $1", [tn])
						.then { |rows|
						rows.first&.then { |row|
							new(Tn::Option.new(
								full_number: row["tel"].sub(/\A\+1/, ""),
								city: row["locality"],
								state: row["region"]
							), row["bandwidth_account_id"], price: row["premium_price"])
						}
					}
				end

				def initialize(tn, bandwidth_account_id, price: 0)
					super(tn)
					@bandwidth_account_id = bandwidth_account_id
					@price = price
				end

				def pending_value
					"LocalInventory/#{tel}/#{@bandwidth_account_id}/#{price}"
				end

				def reserve(*)
					EMPromise.resolve(nil)
				end

				def order(db, _customer)
					# Move always moves to wrong account, oops
					# Also probably can't move from/to same account
					# BandwidthTnRepo.new.move(
					# 	tel, customer.customer_id, @bandwidth_account_id
					# )
					db.exec_defer("DELETE FROM tel_inventory WHERE tel = $1", [tel])
						.then { |r| raise unless r.cmd_tuples.positive? }
				end
			end
		end

		class Q
			def self.register(regex, &block)
				@queries ||= []
				@queries << [regex, block]
			end

			def self.for(q, **kwa)
				q = replace_region_names(q) unless q.start_with?("~")

				@queries.each do |(regex, block)|
					match_data = (q =~ regex)
					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
				end

				raise Fail, "Format not recognized: #{q}"
			end

			def self.replace_region_names(query)
				ISO3166::Country[:US].subdivisions.merge(
					ISO3166::Country[:CA].subdivisions
				).reduce(query) do |q, (code, region)|
					([region.name] + Array(region.unofficial_names))
						.reduce(q) do |r, name|
							r.sub(/#{name}\s*(?!,)/i, code)
						end
				end
			end

			def initialize(q, **)
				@q = q
			end

			def fallback
				[]
			end

			{
				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
			}.each do |k, args|
				klass = const_set(
					args[0],
					Class.new(Q) {
						define_method(:iris_query) do
							{ k => @q }
						end

						define_method(:sql_query) do
							[
								"SELECT * FROM tel_inventory " \
								"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
								"+1#{@q}%"
							]
						end
					}
				)

				args[1..-1].each do |regex|
					register(regex) { |q, **| klass.new(q) }
				end
			end

			class PostalCode < Q
				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))

				def iris_query
					{ zip: @q }
				end

				def sql_query
					nil
				end
			end

			class LocalVanity < Q
				Q.register(/\A~(.+)\Z/, &method(:new))

				def iris_query
					{ localVanity: @q }
				end

				def sql_query
					[
						"SELECT * FROM tel_inventory " \
						"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
						"%#{q_digits}%"
					]
				end

				def q_digits
					@q
						.gsub(/[ABC]/i, "2")
						.gsub(/[DEF]/i, "3")
						.gsub(/[GHI]/i, "4")
						.gsub(/[JKL]/i, "5")
						.gsub(/[MNO]/i, "6")
						.gsub(/[PQRS]/i, "7")
						.gsub(/[TUV]/i, "8")
						.gsub(/[WXYZ]/i, "9")
				end
			end

			class State
				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))

				STATE_MAP = {
					"QC" => "PQ"
				}.freeze

				def initialize(state, **)
					@state = STATE_MAP.fetch(state.upcase, state.upcase)
				end

				def fallback
					[]
				end

				def iris_query
					{ state: @state }
				end

				def sql_query
					[
						"SELECT * FROM tel_inventory " \
						"WHERE available_after < LOCALTIMESTAMP AND region = $1",
						@state
					]
				end

				def to_s
					@state
				end
			end

			class CityState
				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))

				CITY_MAP = {
					"ajax" => "Ajax-Pickering",
					"kitchener" => "Kitchener-Waterloo",
					"new york" => "New York City",
					"pickering" => "Ajax-Pickering",
					"sault ste marie" => "sault sainte marie",
					"sault ste. marie" => "sault sainte marie",
					"south durham" => "Durham",
					"township of langley" => "Langley",
					"waterloo" => "Kitchener-Waterloo",
					"west durham" => "Durham"
				}.freeze

				def initialize(city, state, db: DB, memcache: MEMCACHE)
					@city = CITY_MAP.fetch(city.downcase, city)
					@state = State.new(state)
					@db = db
					@memcache = memcache
				end

				def fallback
					LazyObject.new do
						AreaCodeRepo.new(
							db: @db,
							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
						).find(to_s).sync.map { |area_code|
							AreaCode.new(area_code)
						}
					end
				end

				def iris_query
					@state.iris_query.merge(city: @city)
				end

				def sql_query
					[
						"SELECT * FROM tel_inventory " \
						"WHERE available_after < LOCALTIMESTAMP " \
						"AND region = $1 AND locality = $2",
						@state.to_s, @city
					]
				end

				def to_s
					"#{@city}, #{@state}"
				end
			end
		end
	end
end
