# frozen_string_literal: true

require "bigdecimal/util"
require "nokogiri"

class InteracEmail
	class Error < StandardError
		def self.err(str=nil, &block)
			Class.new(self).tap do |klass|
				klass.define_method("initialize") do |m|
					super(block ? block.call(m) : str)
				end
			end
		end

		# The `m` in these contexts is the raw mail from the library
		NoFrom = err "No 'From' (probably isn't an email)"
		MultipleFrom = err { |m| "More than 1 'From' #{m.from}" }
		BadSender = err "Email isn't from Interac"

		NoSpam = err "No Spam Status"
		BadSPF = err "Don't trust SPF"
		NoAuth = err "Authentication header missing"
		BadAuth = err "Authentication header isn't a pass"
		BadDomain = err "Authentication header isn't for the right domain"
		BadDKIM = err "Don't trust DKIM"
		NoDKIM = err "No DKIM Signature somehow..."
		WrongDKIM = err "DKIM Signature is for a different domain"

		# From here, the m in the error is assumed to be an instance rather than
		# the underlying email object

		NoTxnID = err "No Transaction ID"
		NoTxt = err "No text part"
		BadParagraphs = err "Paragraph structure seems off"
		NoMoney = err { |auto|
			"Couldn't find money in \"#{auto.paragraphs[1]}\""
		}
		BadMoney = err { |auto| "Dollars aren't dollars (#{auto.raw_dollars})" }

		NoMessage = err { |auto|
			"No Message in $%0.2f transfer %s from %s" %
				[
					auto.dollars,
					auto.transaction_id,
					auto.sender_name
				]
		}
		NoJID = err { |auto|
			"No JID in $%0.2f transfer %s from %s with message: %s" %
				[
					auto.dollars,
					auto.transaction_id,
					auto.sender_name,
					auto.message
				]
		}

		MultipleJID = err { |auto|
			"Multiple JIDs in $%0.2f transfer %s from %s with message: %s" %
				[
					auto.dollars,
					auto.transaction_id,
					auto.sender_name,
					auto.message
				]
		}
	end

	AUTO_REGEX =
		/and it has been automatically deposited.$/
		.freeze

	def self.for(m, id_builder: ->(id) { id.to_s })
		Validator.new(m).validate!
		(m.subject =~ AUTO_REGEX ? AutomaticEmail : ManualEmail)
			.new(m, id_builder)
	end

	def initialize(m, id_builder)
		@m = m
		@id_builder = id_builder
	end

	class Validator
		INTERAC_SENDERS = [
			"notify@payments.interac.ca",
			"catch@payments.interac.ca"
		].freeze

		def initialize(m)
			@m = m
		end

		def validate!
			ensure_relevant
			ensure_safe
		end

		def ensure_relevant
			raise Error::NoFrom, @m unless @m.from
			raise Error::MultipleFrom, @m unless @m.from.length == 1
			raise Error::BadSender, @m \
				unless INTERAC_SENDERS.include?(@m.from.first)
		end

		def ensure_safe
			# ensure_spam_checks
			ensure_authentication_header
			ensure_dkim
		end

		def spam_header
			@m["X-Spam-Status"]
				&.value
				&.match(/tests=([^ ]*) /)
				&.[](1)
				&.split(/[,\t]+/)
		end

		def ensure_spam_checks
			spam = spam_header

			raise Error::NoSpam, @m unless spam
			raise Error::BadSPF, @m unless spam.include?("SPF_PASS")
			raise Error::BadDKIM, @m unless spam.include?("DKIM_VALID_AU")
		end

		def authentication_header
			Array(@m["Authentication-Results"]).map(&:value)
		end

		HEADER_REGEX = /\sheader.d=payments.interac.ca\s/.freeze

		def ensure_authentication_header
			auth = authentication_header.find { |a|
				a =~ HEADER_REGEX
			}
			raise Error::NoAuth, @m unless auth
			raise Error::BadAuth, @m unless auth =~ /\sdkim=pass\s/
		end

		def dkim_headers
			# Apparently there can sometimes be multiple DKIM sigs, and this
			# library returns a scalar if there's one, or array otherwise.
			# This Array method, when passed `nil` even returns an emtpy list!
			Array(@m["DKIM-Signature"]).map { |s|
				s.value
				&.split(/;\s*/)
				&.each_with_object({}) { |f, h|
					k, v = f.split("=", 2)
					h[k.to_sym] = v
				}
			}
		end

		def ensure_dkim
			dkim = dkim_headers

			raise Error::NoDKIM, @m if dkim.empty?

			d = "payments.interac.ca"
			raise Error::WrongDKIM, @m unless dkim.any? { |v| v[:d] == d }
		end
	end

	def sender_name
		@m["From"].display_names.first
	end

	def transaction_id
		@m["X-PaymentKey"]&.value.tap { |v|
			raise Error::NoTxnID, self unless v
		}
	end

	def text_part
		@m.text_part.tap { |v| raise Error::NoText, self unless v }
	end

	# First one is "Hi WHOEVER"
	# Second one is "Funds Deposited!\n$NN.NN"
	# Third is funds automatically deposited
	# Fourth is bank account details
	# Fifth is "Transfer Details"
	# Sixth is "Message:\n<MESSAGE>"
	# Seventh is a block of data with date, reference number, sender, amount
	# Then there's a few different ones of footer
	def paragraphs
		# This needs to be a non-capturing group "(?:"
		# Split does a neat thing with groups where it puts
		# the matching groups into the returned list!
		# Neat, but absolutely not what I want
		text_part.decoded.strip.split(/(?:\r\n|\n){2,}/).tap { |v|
			# I don't really use the others, but make sure the
			# first three are there
			raise Error::BadParagraphs, self unless v.length > 6
		}
	end

	def message
		# I used to hard-code a number, but it differs a bit.
		# I figure this is safe because unlike the other fields, this is the
		# user-provided one. So they can't really spoof themselves. It's the
		# other fields I'd be more worried about
		ms = paragraphs.select { |p| p.start_with?("Message:") }
		raise Error::NoMessage, self if ms.empty?

		ms.first.sub(/^Message:\s*/, "")
	end

	DOLLARS_REGEX = /
		# This is a free-spaced regex, so literal spaces
		# don't count as spaces in match
		Funds\s+ Deposited!\s+\$([^ ]*)
	/x.freeze

	def raw_dollars
		paragraphs[1].match(DOLLARS_REGEX)&.[](1).tap { |v|
			raise Error::NoMoney, self unless v
		}
	end

	def dollars
		raw_dollars.delete(",").to_d.tap { |v|
			raise Error::BadMoney, self unless v.positive?
		}
	end

	def xmpp_id
		@id_builder.call(transaction_id)
	end

	def author_xml(xml)
		xml.name sender_name
	end

	def build_xml(xml)
		xml.updated @m.date.iso8601
		xml.id xmpp_id
		xml.generator "interac_email", version: "1.0"
		xml.author do
			author_xml(xml)
		end
		xml.price ("%0.4f" % dollars), xmlns: "https://schema.org"
		xml.priceCurrency "CAD", xmlns: "https://schema.org"
		xml.content to_s, type: "text"
	end

	def to_xml
		Nokogiri::XML::Builder.new { |xml|
			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
				build_xml(xml)
			end
		}.doc.root
	end

	def process
		BlatherNotify.publish "#{transaction_id}": to_xml
	end

	class AutomaticEmail < self
		def jids
			message.scan(/[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+/).tap { |v|
				raise Error::NoJID, self if v.empty?
			}
		end

		def jid
			raise Error::MultipleJID, self unless jids.length == 1

			jids.first.downcase
		end

		def to_s
			"$%0.2f received for %s" % [
				dollars,
				jid
			]
		end

		def author_xml(xml)
			super
			xml.uri "xmpp:#{jid}"
		end

		def build_xml(xml)
			super
			xml.title "💸 Interac E-Transfer #{transaction_id}"
			xml.category term: "interac_automatic", label: "Automatic Interac"
		end
	end

	class ManualEmail < self
		# We don't want people's names to be able to spoof a number,
		# so consume greedily from the start
		REGEX = /
			# This is a free-spaced regex, so literal spaces
			# don't count as spaces in match
			Your\s funds\s await!\s+
			\$([^ ]*)
		/x.freeze

		def raw_dollars
			paragraphs[1].match(REGEX)&.[](1).tap { |v|
				raise Error::NoMoney, self unless v
			}
		end

		def raw_link
			paragraphs[3]&.delete_prefix(
				"RBC Royal Bank: "
			)
		end

		def link
			raw_link || "Couldn't find link"
		end

		def to_s
			"A manual E-Transfer has been received from \"%s\" for " \
			"$%0.2f. Message: %s\nTo deposit go to %s" % [
				sender_name,
				dollars,
				message,
				link
			]
		end

		def build_xml(xml)
			super
			xml.title "⚠️ Manual #{transaction_id}"
			xml.link href: raw_link, type: "text/html" if raw_link
			xml.category term: "interac_manual", label: "Manual Interac"
		end
	end
end
