/* @flow */
"use strict"
type ContextType = {
	b: string;
	c: number;
	v: number;
}
type TokenType = {
	osis: string;
	type: string;
	parts: Array<PartType>;
	laters: Array<string>;
	bookRange?: string;
	bookSequence?: string;
	position?: string;
	subTokens?: Array<TokenType>;
	format?: string;
}
type PartType = {
	type: string;
	subType: string;
	b: string;
	c?: string;
	v?: string;
	laters: Array<string>;
}

function getDefaults() : OptionsType {
	return {
		"b": "$b",
		"c": "$c",
		"v": "$v",
		"-": "-",
		",": ", ",
		".": " ",
		"c.v": ":",
		"$chapters": ["ch", "chs"],
		"$verses": ["v", "vv"],

		"singleChapterFormat": "bv", // Or `b` or `bcv`.
		"singleChapterBooks": ["Obad", "Phlm", "2John", "3John", "Jude", "PrAzar", "SgThree", "Sus", "Bel", "EpJer", "PrMan", "Ps151", "AddPs"],

		"Ps151Format": "bc", // Or `b`.
		"maxPs": 150,
	}
}

function OsisFormatter(): OsisFormatterInterface {
	// Some subset of "Matt.1.2-Mark.3.4".
	const osisFormat: RegExp = /^[1-5A-Za-z]{2,}(?:\.\d{1,3}(?:\.\d{1,3})?)?(?:-[1-5A-Za-z]{2,}(?:\.\d{1,3}(?:\.\d{1,3})?)?)?$/

	let books: BooksType = {}
	let options: OptionsType = getDefaults()

	// Convert an OSIS string, and an optional OSIS context, to human-readable form. Aim for the shortest understandable string: `Matt.1-Matt.2` might become `Matthew 1-2`.
	function format(osisString: string, osisContext: ?string) : string {
		const tokens: Array<TokenType> = createTokens(osisString, osisContext)
		const out: Array<string> = []
		for (let i: number = 0, max: number = tokens.length; i < max; i++) {
			out.push(formatToken(tokens[i]))
		}
		return out.join("")
	}

	// Convert an OSIS string, and an optional OSIS context, to a series of tokens for further processing. The number of tokens matches the number of comma-separated OSIS references in the first argument except some of those references produce no output (e.g., `1John,2John` may produce a formatted `1 and 2 John`). In that case, the extra token(s) are in `.subTokens`.
	function tokenize(osisString: string, osisContext: ?string) : {"tokens": Array<TokenType>} {
		const tokens: Array<TokenType> = createTokens(osisString, osisContext)
		const out: Array<TokenType> = []
		for (let i: number = 0, max: number = tokens.length; i < max; i++) {
			const token: TokenType = tokens[i]
			token.format = formatToken(token)
			out.push(token)
		}
		return {
			tokens: out
		}
	}

	function createTokens(osisString: string, osisContext: ?string) : Array<TokenType> {
		if (typeof osisString !== "string") {
			throw "OsisFormatter: first argument (OSIS) must be a string."
		}
		// Create a context object, using the supplied context (if available).
		const context: ContextType = setContext(osisContext)
		// Separate the supplied OSISes into individual OSIS references.
		const osises: Array<string> = osisString.split(",")
		const tokens: Array<TokenType> = []
		while (osises.length > 0) {
			// Make sure we're dealing with a valid OSIS string. It throws an exception if there's a problem.
			const osis: string = normalizeOsis(osises.shift())
			// Tokenize the OSIS string.
			tokens.push(osisToToken(osis, context))
			// Add a separator token if there are more OSIS strings to tokenize. We don't want to add it at the end of the loop, which is why we use `while (true)` at the top.
			if (osises.length > 0) {
				tokens.push({
					osis: ",",
					type: ",",
					parts: [],
					laters: []
				})
			}
		}
		// Calculate data that we may need about future tokens in the sequence.
		return annotateTokens(tokens)
	}

	// Format a single token.
	function formatToken(token: TokenType) : string {
		// First check to see if we have a book range or sequence to handle specially (`1John-2John` might become `1-2 John`, or `1John,2John` might become `1 and 2 John`).
		const bookProperties: Array<string> = ["bookRange", "bookSequence"]
		while (bookProperties.length > 0) {
			// This somewhat convoluted syntax is to satisfy Flow.
			const property: string = bookProperties.shift()
			if (typeof token[property] !== "string") {
				continue
			}
			const book: void | Array<string> = books[token[property]]
			if (book !== undefined && typeof book[0] === "string") {
				return book[0]
			}
		}

		// Most of the time, iterate over its `parts` to build the output string.
		const out: Array<string> = []
		for (let i: number = 0, max: number = token.parts.length; i < max; i++) {
			out.push(formatPart(token, token.parts[i]))
		}
		return out.join("")
	}

	// Format a `part` in a `token.parts`.
	function formatPart(token: TokenType, part: PartType) : string {
		let prefix: string = ""
		switch (part.type) {
			case "c":
			case "v":
				// If we specify a specific format for this `subType`, then calculate the value to prepend to the final output. For example, `^v` when a verse appears first in a string (when `format()` has a `context`) might want a `vv. ` prefix.
				if (part.subType !== "" && typeof options[part.subType] !== "undefined") {
					prefix = formatVariable(part.subType, part, token)
				}
			// Fall through to `b`, which uses the same logic.
			case "b":
				return prefix + formatVariable(part.type, part, token)
			// Punctuation tokens all use the same logic. They never have prefixes because they can never appear first in a string.
			case ".":
			case "-":
			case ",":
			// Falls through. `default` is only here to satisfy code coverage. There are no other cases.
			default:
				return formatVariable(getBestOption(part.type, part.subType, token.position), part, token)
		}
	}

	// `options` can contain partial matches: `bc-bc`, `bc-b`, `c-bc`, `c-b`, `-bc`, `-b`, and `-` all match a `bc-bc` string. Take the best match that exists in options.
	function getBestOption(splitChar: string, option: string, position: void | string) : string {
		let [pre, post]: Array<string> = option.split(splitChar)
		// If we want to do special formatting based on the position of the punctuation--for example, changing the last sequence separator to " and ". `position`, when set, is `&` or `,&`.
		if (typeof position === "string" && typeof options[position] === "string") {
			splitChar = position
		}
		const postChars: string = post
		// Start by matching the full string. Progressively remove ending possibilities and then beginning possibilities. For `bc-bc`, it tries to find options in the following order, knowing that the last one, the `splitChar` on its own, will always match: `bc-bc`, `bc-b`, `c-bc`, `c-b`, `-bc`, `-`.
		for (let i: number = 0, length: number = pre.length; i <= length; i++) {
			post = postChars
			while (post.length > 0) {
				if (typeof options[`${pre}${splitChar}${post}`] === "string") {
					return `${pre}${splitChar}${post}`
				}
				// Remove the last character so that we match characters closer to `splitChar` (in `-bcv`, the `v` is the least-important part).
				post = post.substr(0, post.length - 1)
			}
			pre = pre.substr(1)
		}
		return splitChar
	}

	// Some values can have variables indicated by a `$`. Replace them if we can.
	function formatVariable(optionsKey: string, part: PartType, token: TokenType) : string {
		let pattern: string = options[optionsKey]
		// We can short-circuit all this logic if there are no `$`.
		if (pattern.indexOf("$") === -1) {
			return pattern
		}
		// Replace `$chapters` with a literal value like `ch.` or `chs.` It's a RegExp rather than a string to allow the `/g` flag.
		pattern = pattern.replace(/\$chapters/g, function() : string {
			// If `books` defines a string to use (e.g., with `Ps.$chapters`, maybe you want "Ps. 3" rather than "ch. 3"), use that instead.
			const arrayToUse: Array<string> = (typeof books[`${part.b}.$chapters`] === "undefined") ? options.$chapters : books[`${part.b}.$chapters`]
			// Only retun the plural if there's a plural variant to use and there are later chapters.
			if (arrayToUse.length > 1 && multipleChaptersPosition(part, token) > 0) {
				return arrayToUse[1]
			}
			return arrayToUse[0]
		})
		// It's a RegExp rather than a string to allow the `/g` flag.
		pattern = pattern.replace(/\$verses/g, function() : string {
			// Unlike `$chapters`, `$verses` doesn't require a check inside `books` since `Ps.1.2-Ps.1.3` is going to be `vv. 2-3`.
			if (options.$verses.length > 1 && hasMultipleVerses(part, token) === true) {
				return options.$verses[1]
			}
			return options.$verses[0]
		})
		pattern = pattern.replace(/\$b/g, function() : string {
			// We know that `part.b` exists in `books` because it would have already thrown an exception in `osisWithContext` or `setContext`.
			const maxPosition: number = books[part.b].length - 1
			// If there's only one possible book name, use that.
			if (maxPosition === 0) {
				return books[part.b][0]
			}
			// Otherwise, calculate the one that we prefer to use. [0] = singular; [1] = plural; [2] = book on its own.
			const preferredPosition: number = multipleChaptersPosition(part, token)
			if (preferredPosition > maxPosition) {
				return books[part.b][maxPosition]
			}
			return books[part.b][preferredPosition]
		})
		// Don't accidentally insert `undefined` into a string.
		pattern = pattern.replace(/\$c/g, (typeof part.c === "string") ? part.c : "")
		pattern = pattern.replace(/\$v/g, (typeof part.v === "string") ? part.v : "")
		return pattern
	}

	// If a range or sequence has multiple chapters, we may want to change the output: `chapters 1-2` rather than `chapter 1-2`, for example. The return value is 0-2, indicating which `books[osis][]` is preferred.
	function multipleChaptersPosition(part: PartType, token: TokenType) : number {
		// If we're looking at a whole book and there are multiple chapters in the book, we know there are multiple chapters.
		if (token.type === "b" && !isSingleChapterBook(part.b)) {
			// It's a book on its own, so prefer the full-book abbreviation if available.
			return 2
		}
		// If we're currently looking at a chapter, include it in our calculations.
		let later: string = (part.type === "c") ? "c" : ""
		// All the part types until the next book.
		later += part.laters.join("") + "," + token.laters.join(",")
		// A chapter range always has multiple chapters.
		if (later.indexOf("-c") >= 0) {
			return 1
		}
		// An unusual situation, a Psalms range into another book (e.g., `Ps.149-Prov.1` or `Ps.150-Prov.1`). In the first case, we want to use the plural; in the second, we want to use the singular. `later` only has a `-b` if it's in the current `part`--`token.laters` ends before it reaches the next book in a sequence.
		if (part.b === "Ps" && later.indexOf("-b") >= 0) {
			// Find the chapter number.
			for (let i: number = 0, max: number = token.parts.length; i < max; i++) {
				const otherPart: PartType = token.parts[i]
				if (otherPart.type === "c") {
					// If it's less than the number of Psalms, we know that more than one chapter is involved.
					if (parseInt(otherPart.c, 10) < options.maxPs) {
						return 1
					}
					// Once we've looked at the chapter, we don't need to look any further. We know this situation doesn't apply.
					break
				}
			}
			return 0
		}
		// If there are two or more chapter instances in a sequence, we know there are multiple chapters. "cc" = "", "", "".
		if (later.split("c").length > 2) {
			return 1
		}
		return 0
	}

	// Possibly handle `verse 1` and `verses 1-2` differently.
	function hasMultipleVerses(part: PartType, token: TokenType) : boolean {
		// If we're currently looking at a verse, include it in our calculations.
		let later: string = (part.type === "v") ? "v" : ""
		later += part.laters.join("") + "," + token.laters.join(",")
		// We only care about the current chapter.
		const [thisChapter]: [string] = later.split("c")
		// If there's a range, we know there are multiple verses.
		if (thisChapter.indexOf("-") >= 0) {
			return true
		}
		// "vv" = "", "", "".
		if (thisChapter.split("v").length > 2) {
			return true
		}
		return false
	}

	// Add data we'll use later to construct the final output.
	function annotateTokens(tokens: Array<TokenType>) : Array<TokenType> {
		const out: Array<TokenType> = []
		// We only need `prevToken` in a sequence, which is never first, so it's OK that this value is wrong on the first loop run.
		let prevToken: TokenType = tokens[0]
		let i: number = 0
		// `annotateToken` can change the length of this array if there's a `bookSequence`.
		while (tokens.length > 0) {
			const token: TokenType = tokens.shift()
			// Never first.
			if (token.type === ",") {
				// If there's a bookSequence, use the context from the last actual token even though we've moved the token to a `subTokens` array.
				if (typeof prevToken.subTokens !== "undefined") {
					prevToken = prevToken.subTokens[prevToken.subTokens.length - 1]
				}
				annotateSequenceToken(token, prevToken, tokens)
			} else {
				annotateToken(token, tokens, i)
			}
			// Add future context.
			annotateTokenLaters(token, tokens)
			annotateTokenParts(token.parts)
			// Add a `position` property to the last `sequence` token.
			if (tokens.length === 0 && out.length > 1) {
				// If there are only two items, we know that there will be three items total: a reference token, a sequence, and a yet-to-be-added reference token.
				out[out.length - 1].position = out.length === 2 ? "&" : ",&"
			}
			out.push(token)
			prevToken = token
			i++
		}
		return out
	}

	// Add data to a single token that we can't derive elsewehre.
	function annotateToken(token: TokenType, tokens: Array<TokenType>, i: number) : void {
		// The first `part.type` could be `c` or `v` if `format()` is provided a start context.
		if (i === 0 && token.parts[0].type !== "b") {
			// `c` or `v` or `cv`.
			const [pre]: Array<string> = token.type.split("-")
			let prefix: string = ""
			// Note that we're dealing with a single-chapter book in case we want to handle it differently.
			if (isSingleChapterBook(token.parts[0].b)) {
				prefix = "b1"
			}
			// Set the `subType` (`c` and `v` don't normally have a `subType`) for later use.
			token.parts[0].subType = `${prefix}^${pre}`
		}
	}

	// Create a sequence `token.parts` with the keys we'll need later.
	function annotateSequenceToken(token: TokenType, prevToken: TokenType, tokens: Array<TokenType>) : void {
		// In the case of a `bookSequence`, we've already selected the `prevToken` we want before we call this function.
		const prevPart: PartType = prevToken.parts[prevToken.parts.length - 1]
		// If preceded or followed by a range, only use the parts closest to the sequence token: `bcv-cv,bc-v` returns the subType `cv,bc`.
		const prevTypes: Array<string> = prevToken.type.split("-")
		const nextTypes: Array<string> = tokens[0].type.split("-")
		token.parts = [{
			type: ",",
			// Indicates the preceding and following token types so that we can join different types differently if we want.
			subType: `${prevTypes.pop()},${nextTypes[0]}`,
			b: prevPart.b,
			c: prevPart.c,
			v: prevPart.v,
			laters: []
		}]
	}

	// Fill in the `laters` array for each token.
	function annotateTokenLaters(token: TokenType, tokens: Array<TokenType>) : void {
		const currentType: string = token.type
		// An array of later sequential books, in case we want to format the sequence specially (e.g., `1John-2John` might become `1 -2 John`).
		const bookSequence: Array<string> = []
		// If we have a `b` sequence, we want to continue past when we would normally stop.
		let keepCheckingBooks: boolean = false
		// But we still want to stop adding `token.laters`.
		let latersDone: boolean = false
		if (currentType === "b") {
			bookSequence.push(token.parts[0].b)
			keepCheckingBooks = true
		}

		for (let i: number = 0, max: number = tokens.length; i < max; i++) {
			const laterToken: TokenType = tokens[i]
			const laterType: string = laterToken.type
			// Doing it this way avoids possible leading and trailing `,`.
			if (laterType === ",") {
				continue
			}
			// If we're still in a `b` sequence, keep going.
			if (keepCheckingBooks === true) {
				if (laterType === "b") {
					bookSequence.push(laterToken.parts[0].b)
				} else {
					keepCheckingBooks = false
				}
			}
			// Stop here if we've already encountered a book and don't need to go further.
			if (latersDone === true) {
				continue
			}
			// Only go as far as the next book.
			if (laterType.indexOf("b") >= 0) {
				const [pre]: Array<string> = laterType.split("b")
				// Only add it to the array if there's something to add. We don't care about empty strings.
				if (pre.length > 0) {
					token.laters.push(pre)
				}
				if (keepCheckingBooks === false) {
					break
				}
				latersDone = true
			// If there's not a book in `laterType`, then we know we need to keep looking until we find one or reach the end of the array.
			} else {
				token.laters.push(laterType)
			}
		}
		if (bookSequence.length > 1) {
			addBookSequence(token, bookSequence, tokens)
		}
	}

	// If given a sequence like `1John,2John`, we may want to turn that into `1 and 2 John`.
	function addBookSequence(token: TokenType, sequenceArray: Array<string>, tokens: Array<TokenType>) : void {
		// `sequenceArray` includes the current token. If `length === 1`, then the only item left in the array is the current token, which by definition isn't part of a sequence.
		while (sequenceArray.length > 1) {
			// Look for a comma-joined sequence in `books`.
			const bookSequence: string = sequenceArray.join(",")
			if (typeof books[bookSequence] !== "undefined" && typeof books[bookSequence][0] === "string") {
				token.bookSequence = bookSequence
				// Remove the number of items in the sequence. Let's say there are three items: `["1John", "2John", "3John"]`. `1John` is the current token, which is already gone from the array. That means we need to hop ahead two books, or `3 - 1`, to prevent us from processing the tokens. The `* 2` removes sequence tokens (`,`) as well as book tokens--`tokens[0]` is a sequence, followed by a book, then sequence, then book, etc.
				token.subTokens = tokens.splice(0, (sequenceArray.length - 1) * 2)
				// Once we've found a match and have cleaned up later tokens, we're done.
				return
			}
			sequenceArray.pop()
		}
		// Most of the time, don't make any changes to `token`.
	}

	// Add `laters` to each `token.parts`.
	function annotateTokenParts(parts: Array<PartType>) : void {
		const laters: Array<string> = []
		const max: number = parts.length
		// First we need to know what all the `laters` are.
		for (let i: number = 0; i < max; i++) {
			laters.push(parts[i].type)
		}
		// Then we can add them to each `part`.
		for (let i: number = 0; i < max; i++) {
			// The first element is the current type.
			laters.shift()
			// Create a copy rather than a reference and remove spacing parts (`.`).
			parts[i].laters = laters.filter(function(value: string) : boolean {
				return value !== "."
			})
		}
	}

	// Convert an OSIS string to a single token.
	function osisToToken(osis: string, context: ContextType) : TokenType {
		// `end` may be undefined.
		const [start, end]: Array<string> = osis.split("-")
		const startToken: TokenType = osisWithContext(start, context)
		// If there's  no `end`, we don't need to do any further processing. `startToken` is itself a complete token.
		if (end === undefined) {
			return startToken
		}
		const endToken: TokenType = osisWithContext(end, context)
		// Construct a unified set of `parts`, including the range.
		const parts: Array<PartType> = startToken.parts
		parts.push(makeRangePart(startToken, endToken))

		const token: TokenType = {
			osis,
			type: `${startToken.type}-${endToken.type}`,
			// Add the end `parts` to the existing `parts` array.
			parts: parts.concat(endToken.parts),
			laters: []
		}
		// We may want to handle certain book-only ranges differently (`1John-2John` might be `1-2 John`).
		if (token.type === "b-b") {
			token.bookRange = startToken.parts[0].b + "-" + endToken.parts[0].b
		}
		return token
	}

	// Construct a range token with data from one preceding and following token.
	function makeRangePart(startToken: TokenType, endToken: TokenType) : PartType {
		const prevPart: PartType = startToken.parts[startToken.parts.length - 1]
		const range: PartType = {
			type: "-",
			// To format it later, we may need to know what kind of objects it's joining.
			subType: `${startToken.type}-${endToken.type}`,
			b: prevPart.b,
			laters: []
		}
		// Only add chapter and verse values if they exist in the previous part. These values reflect the current context, not the future context.
		if (typeof prevPart.c !== "undefined") {
			range.c = prevPart.c
		}
		if (typeof prevPart.v !== "undefined") {
			range.v = prevPart.v
		}
		return range
	}

	// Tokenize a non-range OSIS string, taking context into account. The goal is to omit needless parts: if your context is `Matt.1` and the current OSIS is `Matt.2`, we can omit the book from the return token.
	function osisWithContext(osis: string, context: ContextType) : TokenType {
		// `c` and `v` may be undefined. `c` always exists if `v` does.
		const [b, c, v]: Array<string> = osis.split(".")
		// Don't try to guess if we encounter an unexpected book.
		if (typeof books[b] === "undefined") {
			throw `Unknown OSIS book: "${b}" (${osis})"`
		}
		const token: TokenType = {
			osis,
			type: "",
			parts: [],
			laters: []
		}
		const isSingleChapter: boolean = isSingleChapterBook(b)
		// If there's an end verse, if we've set the relevant option, and if the book only has one chapter, then we want to omit the chapter: `Phlm.1.1` = `Philemon 1`.
		const omitChapter: boolean = isSingleChapter && typeof v === "string" && (options.singleChapterFormat === "bv" || options.singleChapterFormat === "b")
		// Returns true if there's a chapter. It modifies `token` in-place.
		if (osisBookWithContext(b, c, v, isSingleChapter, omitChapter, context, token) === false) {
			return token
		}
		// Returns true if there's a verse. It modifies `token` in-place.
		if (osisChapterWithContext(b, c, v, omitChapter, context, token) === false) {
			return token
		}
		// We know there's a verse.
		token.type += "v"
		token.parts.push({
			type: "v",
			subType: "",
			b,
			c,
			v,
			laters: []
		})
		context.v = parseInt(v, 10)
		return token
	}

	// Handle a "book" part.
	function osisBookWithContext(b: string, c: ?string, v: ?string, isSingleChapter: boolean, omitChapter: boolean, context: ContextType, token: TokenType) : boolean {
		// If we're looking at something like `Phlm-Phlm.1`, we may want to treat `Phlm.1` as a book rather than include a chapter. This is unusual.
		const returnSingleBook: boolean = v === undefined && isSingleChapter === true && options.singleChapterFormat === "b"

		// Gen.1,Exod = "Exod" || Gen.1,Gen = "Gen".
		if (b !== context.b || c === undefined || returnSingleBook) {
			// Do it this way rather than `context = {...}` to keep the reference to the original object.
			context.b = b, context.c = 0, context.v = 0
			token.parts.push({
				type: "b",
				subType: "",
				b,
				laters: []
			})
			token.type = "b"
			// There's no chapter, or there's no verse and we only want the whole book, so we don't need to continue.
			if (c === undefined || returnSingleBook) {
				return false
			}
			// In general, the `subType` will be `b.c`.
			let subType: string = "b.c"
			// If we want to omit the chapter reference in a single-chapter book.
			if (omitChapter === true) {
				subType = "b.v"
			// It's a single-chapter book but we do want to show the chapter: `"singleChapterFormat": "bcv"` or `"bv"` without a verse (`Phlm.1`).
			} else if (isSingleChapter === true) {
				// `b1` means a single-chapter book.
				subType = "b1.c"
			}
			// We know there's a chapter, so insert the joiner.
			token.parts.push({
				type: ".",
				subType,
				b,
				laters: []
			})
		}
		// At this point, we know that it's the same book as in `context` and there's a chapter.
		return true
	}

	// Handle a "chapter" part.
	function osisChapterWithContext(b: string, c: string, v: ?string, omitChapter: boolean, context: ContextType, token: TokenType) : boolean {
		// We already know that the context book and current book are the same.
		if (parseInt(c, 10) !== context.c || v === undefined) {
			// We need to set `context.v` because we don't know that `osisBookWithContext()` reset it.
			context.c = parseInt(c, 10), context.v = 0
			// If only `Phlm.1`, we want to include the chapter if `options.singleChapterFormat === "bv"` or `"bcv"`, but omit the chapter if it's `=== "b"`. `omitChapter` is always `false` when `v === undefined`. We just want to set the context in this case.
			if (omitChapter === true) {
				return true
			}
			// Otherwise, add the chapter part.
			token.parts.push({
				type: "c",
				subType: "",
				b,
				c,
				laters: []
			})
			token.type += "c"
			// There's no verse, so we don't need any further processing.
			if (v === undefined) {
				return false
			}
			// We know there's a verse, so insert the joiner.
			token.parts.push({
				type: ".",
				subType: "c.v",
				b,
				laters: []
			})
		}
		// Otherwise, we know that it's the same book and chapter as in `context` and that there's a verse.
		return true
	}

	// Only checks if the book is in an array in `options`.
	function isSingleChapterBook(b: string) : boolean {
		return options.singleChapterBooks.indexOf(b) >= 0
	}

	// Confirm that the string matches the expected format of an OSIS string. Throw an exception if not. Also handle Ps151 quirks.
	function normalizeOsis(osis: string) : string {
		if (osisFormat.test(osis) === false) {
			throw `Invalid osis format: '${osis}'`
		}
		// If we want to treat Ps151 as just another Psalm (so `Ps151.1.2` might output `Psalm 151:2`).
		if (options.Ps151Format === "bc") {
			// Remove the chapter and treat it as `Ps.151`.
			osis = osis.replace(/(?:Ps151|AddPs)(?:\.\d+\b)?/g, "Ps.151")
		}
		return osis
	}

	// Given an optional string context, create a `context` obect.
	function setContext(osis: ?string) : ContextType {
		// We always only want these three keys. Flow doesn't like calling `Object.seal`, however.
		const context: ContextType = {
			b: "",
			c: 0,
			v: 0
		}
		// There's no provided context.
		if (osis == null) {
			return context
		}
		// Don't allow sequences.
		osis = normalizeOsis(osis)
		// Use the end value of the range if there is one.
		if (osis.indexOf("-") >= 0) {
			const [, end]: Array<string> = osis.split("-")
			return setContext(end)
		}
		const [b, c, v]: Array<string> = osis.split(".")
		if (typeof books[b] === "undefined") {
			throw `Unknown OSIS book provided for "context": "${b}" (${osis})"`
		}
		// `b` always exists.
		context.b = b
		// `c` and `v` don't necessarily exist. `v` only exists if `c` does.
		if (typeof c === "string") {
			context.c = parseInt(c, 10)
			if (typeof v === "string") {
				context.v = parseInt(v, 10)
			}
		}
		return context
	}

	// Override any default parameters with user-supplied parameters. Also make sure `userOptions` has all the keys we need. Each call is independent, which means it has no memory between calls; `userOptions` should contain all the keys to override defaults.
	function setOptions(userOptions: OptionsType) : void {
		// Reset them to the default.
		options = getDefaults()
		// No need to match properties if there's no argument.
		if (userOptions == null) {
			return
		}
		// We want to ensure type consistency, so we don't just use `Object.assign`. Flow complains if we just iterate over `Object.keys()`.
		const userKeys: Array<string> = Object.keys(userOptions)
		for (let i: number = 0, max: number = userKeys.length; i < max; i++) {
			const key: string = userKeys[i]
			const defaultType: string = typeof options[key]
			// If it's not in `defaults`, or if its type matches, set it.
			if (defaultType === "undefined" || typeof userOptions[key] === defaultType) {
				options[key] = userOptions[key]
			// Otherwise, the types don't match.
			} else {
				throw `Invalid type for options["${key}"]. It should be "${defaultType}".`
			}
		}
	}

	// Set valid books and abbreviations. It takes an object where each key is the OSIS book (e.g., `Matt`), and each value is a one-, two-, or three-item array. The first item is the book name to use, the second item is the book name to use for plural cases, and the third item is to use when the book appears on its own. For example: `{"Ps": ["Psalm", "Psalms"]}`. You can also use a special key of the type `OSIS.$chapters` (e.g., `Ps.$chapters`), which overrides any chapter abbreviations. For example, `{"Ps.$chapters": ["Ps.", "Pss."]` could result in `Psalms 1:2, Pss. 3, 4` if given the OSIS `Ps.1.2,Ps.3,Ps.4`.
	function setBooks(userBooks: BooksType) : void {
		books = {}
		Object.keys(userBooks).forEach(function(key: string) : void {
			const value: Array<string> = userBooks[key]
			if (Array.isArray(value) === false) {
				throw `books["${key}"] should be an array: ${Object.prototype.toString.call(value)}.`
			}
			if (value.length < 1 || value.length > 3) {
				throw `books["${key}"] should have exactly 1, 2, or 3 items. `
			}
			books[key] = value
		})
	}

	return {
		format,
		tokenize,
		setOptions,
		setBooks
	}
}

/* global module */
module.exports = OsisFormatter
