// NavigationState diff is an array of state changes.
// A change consists of an address of the object on which the operation should be performed, and the new value.
// The address part is represented in a url-compatible string as a list of keys separated by dots. Dots in keys are escaped.
// Address may contain a ~ to indicade the private part of the navigation state. Only a single such tilda-key can exist in the address part. Tildas in keys are escaped.
// All address keys are uri-encoded.
// The value part is a uri-encoded new value. A single ~ is a special value indicating that the key at the address should be removed. If the value to be set is a single tilda - it's escaped.


// TODO: return nice errors on borked diffs
// TODO: maybe don't escape a leading "-" in address?

import { NavigationState } from "./navigation_state.mjs"

const priv = Symbol()

class NavigationStateChange {
	#address
	#value

	constructor(address, value) {
		this.#address = address
		this.#value = value
		this.is_private = address.find((key) => key == priv) != null
		Object.freeze(this)
	}

	static from_string_pair(address_and_op_string, value) {
		return new NavigationStateChange(address_and_op_string.split(".").map((key)=>key == '~' ? priv : decodeURIComponent(key)), value == '~' ? undefined : decodeURIComponent(value))
	}

	as_string_pair() {
		return [
			this.#address.map((key) => key == priv ? "~" : encodeURIComponent(key).replace(/[=\.~]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join(".").replace(/^-/, "%2D"),
			this.#value === undefined ? '~' : encodeURIComponent(this.#value).replace(/^~$/, "%7E")
		]
	}

	apply(state) {
		const priv_at = this.#address.findIndex((key) => key === priv)
		const subaddress_starts_at = (priv_at != -1) ? priv_at : (this.#address.length-1)
		if (this.#value === undefined) {
			const apply_change_on = this.#address.slice(0, subaddress_starts_at).reduce((object, key) => object?.get(key), state)
			if (this.#address[subaddress_starts_at] === priv)
				apply_change_on?.private_remove(this.#address.slice(subaddress_starts_at + 1, this.#address.length))
			else
				apply_change_on?.remove(this.#address.slice(subaddress_starts_at, this.#address.length))
		} else {
			const apply_change_on = this.#address.slice(0, subaddress_starts_at).reduce((object, key) => object.scope(key), state)
			if (this.#address[subaddress_starts_at] === priv)
				apply_change_on.private_set(this.#address.slice(subaddress_starts_at+1, this.#address.length), this.#value)
			else
				apply_change_on.set(this.#address.slice(subaddress_starts_at, this.#address.length), this.#value)
		}
	}
}


export class NavigationStateDiff {
	#changes

	static #element_diff(address, element1, element2) {
		switch (typeof element2) {
			case 'undefined': return [new NavigationStateChange(address, undefined)]
			case 'string': return NavigationStateDiff.#string_diff(address, typeof element1 == 'string' ? element1 : undefined, element2)
			case 'object':
				if (element2 instanceof NavigationState)
					return NavigationStateDiff.#navigation_state_diff(address, typeof element1 == 'object' ? element1 : {}, element2)
				else
					return NavigationStateDiff.#object_diff(address, typeof element1 == 'object' ? element1 : {}, element2)
			default: throw `Unexpected argument encountered. Idk what to do. We've got something of type: ${typeof element2}`
		}
	}

	static #string_diff(address, string1, string2) {
		return string1 == string2 ? [] : [new NavigationStateChange(address, string2)]
	}

	static #object_diff(address, object1, object2) {
		return Array.from(new Set(Object.keys(object1).concat(Object.keys(object2)))).map((key)=>
			NavigationStateDiff.#element_diff(address.concat([key]), object1[key], object2[key])
		).flat()
	}

	static #navigation_state_diff(address, state1, state2) {
		return Array.from(new Set(Object.keys(state1?.shared || {}).concat(Object.keys(state2.shared)))).map((key)=>
			NavigationStateDiff.#element_diff(address.concat([key]), state1?.shared?.[key], state2.shared[key])
		).concat(NavigationStateDiff.#object_diff(address.concat([priv]), state1?.private || {}, state2.private)).flat()
	}


	static from_states(state1, state2) {
		return new NavigationStateDiff(NavigationStateDiff.#navigation_state_diff([], state1.root_scope(), state2.root_scope()))
	}

	static from_string_pairs(pairs) {
		return new NavigationStateDiff(pairs.map(([op_and_address, value])=>NavigationStateChange.from_string_pair(op_and_address, value)))
	}

	static exposed_diff(state, addresses) {
		return new NavigationStateDiff(addresses.map((address) => {
			//TOOD: also accept array instead of string
			const address_array = address.split(".").map(decodeURIComponent)
			const value = state.get(address_array)
			return (value == undefined || (typeof value == 'object')) ? null : new NavigationStateChange(address_array, value)
		}).filter(change=> change!=null))
	}

	as_string_pairs() {
		return this.#changes.map((operation) => operation.as_string_pair())
	}

	constructor(operations_array) {
		this.#changes = operations_array
		this.is_private = operations_array.find((change)=>change.is_private) != null
	}

	stack(other_diff) {
		return new NavigationStateDiff(this.#changes.concat(other_diff.#changes))
	}

	patch(old_state) {
		const new_state = NavigationState.create(old_state.root_scope()).editable()
		for (const diff_operation of this.#changes) {
			diff_operation.apply(new_state)
		}
		return new_state.freeze()
	}

}
