import { StateManagerProtected } from "./state_manager_protected.mjs"
import { NavigationStateProtected } from "./navigation_state_protected.mjs"


//TODO: maybe root_scope() should return app-state-scope's root instead of the absolute root
//TODO: implement `changed?` function
//TODO: maybe only unfreeze if really needed
//TODO: fail on wrong types
//TODO: hide "create" and instead allow creating empty state from StateManager. then don't re-export this class.
//TODO: turn statics into # maybe

const objectify_for_history = Symbol()
const deobjectify_from_history = Symbol()


export class NavigationState {
	static manager

	#shared
	#private
	#parent
	#address
	static #constructor_call_token = Symbol()

	constructor(token, parent, address) {
		if (token !== NavigationState.#constructor_call_token) throw "Use NavigationState.create() instead. Thx."
		this.#parent = parent
		this.#address = address
		this.#shared = {}
		this.#private = {}
		Object.freeze(this)
		Object.freeze(this.#address)
	}

	static create(param = {}) {
		return new NavigationState(NavigationState.#constructor_call_token, null, []).merge(param).freeze()
	}

	get shared() {
		return new Proxy(this.#shared, { set(_obj, _prop, _value) { return false }})
	}

	get private() {
		return new Proxy(this.#private, { set(_obj, _prop, _value) { return false }})
	}


	scope(address) {
		return this.#refreeze([address].flat().reduce((current, key) => {
			if (current.#shared[key] instanceof NavigationState) return current.#shared[key]
			current = current.editable()
			return current.#shared[key] = new NavigationState(NavigationState.#constructor_call_token, current, current.#address.concat([key]))
		}, this))
	}

	root_scope() {
		return this.#parent?.root_scope() || this
	}

	parent_scope() {
		return this.#parent
	}


	merge(param1, param2) {
		let [address_array, to_merge] = param2 != null ? [[param1].flat(), param2] : [[], param1]
		if (!(typeof to_merge == 'object')) throw "The thing to merge must be an object"
		const unfrozen = this.editable()
		const scope_to_change = unfrozen.scope(address_array)
		for (const [key, value] of Object.entries(to_merge instanceof NavigationState ? to_merge.#shared : to_merge)) {
			if (value == null) {
				delete scope_to_change.#shared[key]
			} else if (typeof value === 'string') {
				scope_to_change.#shared[key] = value
			} else if (typeof value === 'object') {
				if (! (scope_to_change.#shared[key] instanceof NavigationState)) {
					scope_to_change.#shared[key] = new NavigationState(NavigationState.#constructor_call_token, scope_to_change, scope_to_change.#address.concat([key]))
				}
				scope_to_change.#shared[key].merge(value)
			}
		}
		if (to_merge instanceof NavigationState) scope_to_change.private_merge(to_merge.#private)
		return this.#refreeze(this.#pivot(unfrozen))
	}

	private_merge(param1, param2) {
		let [address_array, to_merge] = param2 != null ? [[param1].flat(), param2] : [[], param1]
		if (!(typeof to_merge == 'object')) throw "The thing to merge must be an object"
		const unfrozen = this.editable()
		const object_to_change = address_array.reduce((current, key) => typeof current[key] === 'object' ? current[key] : current[key] = {}, unfrozen.#private)
		NavigationState.#merge_objects(object_to_change, to_merge)
		return this.#refreeze(this.#pivot(unfrozen))
	}


	set(address, new_value) {
		const address_array = [address].flat()
		const unfrozen = this.editable()
		const scope_to_change = unfrozen.scope(address_array.slice(0, -1))
		delete scope_to_change.#shared[address_array.at(-1)]
		if (typeof new_value === 'string') {
			scope_to_change.#shared[address_array.at(-1)] = new_value
		} else if (typeof new_value === 'object') {
			scope_to_change.merge(address_array.at(-1), new_value)
		}
		return this.#refreeze(this.#pivot(unfrozen))
	}

	private_set(address, new_value) {
		const address_array = [address].flat()
		const unfrozen = this.editable()
		const object_to_change = address_array.slice(0, -1).reduce((current, key) => typeof current?.[key] === 'object' ? current[key] : current[key] = {}, unfrozen.#private)
		delete object_to_change[address_array.at(-1)]
		if (typeof new_value == 'string') {
			object_to_change[address_array.at(-1)] = new_value
		} else {
			NavigationState.#merge_objects(object_to_change[address_array.at(-1)] = {}, new_value)
		}
		return this.#refreeze(this.#pivot(unfrozen))
	}


	remove(address) {
		const address_array = [address].flat()
		const unfrozen = this.editable()
		const scope_to_change = address_array.slice(0, -1).reduce((current, key) => current?.#shared?.[key] instanceof NavigationState ? current.#shared[key] : null, unfrozen)
		if (scope_to_change != null) delete scope_to_change.#shared[address_array.at(-1)]
		return this.#refreeze(this.#pivot(unfrozen))
	}

	private_remove(address) {
		const address_array = [address].flat()
		const unfrozen = this.editable()
		const object_to_change = address_array.slice(0, -1).reduce((current, key) => typeof current?.[key] === 'object' ? current[key] : null, unfrozen.#private)
		if (object_to_change != null) delete object_to_change[address_array.at(-1)]
		return this.#refreeze(this.#pivot(unfrozen))
	}


	get(address) {
		const address_array = [address].flat()
		return address_array.slice(0, -1).reduce((current, key) => current?.#shared?.[key] instanceof NavigationState ? current.#shared[key] : null, this)?.#shared?.[address_array.at(-1)]
	}

	private_get(address) {
		const address_array = [address].flat()
		return address_array.slice(0,-1).reduce((current, key) => typeof current?.[key] === 'object' ? current[key] : null, this.#private)?.[address_array.at(-1)]
	}


	strip_private_recursively() {
		const unfrozen = this.editable()
		for (const key in unfrozen.#private) delete unfrozen.#private[key]
		for (const value of Object.values(unfrozen.#shared))
			if (value instanceof NavigationState) value.strip_private_recursively()
		return this.#refreeze(this.#pivot(unfrozen))
	}

	keep_only_values(address, array_or_string_or_function) {
		const address_array = [address].flat()
		const unfrozen = this.editable()
		const scope_to_change = address_array.slice(0, -1).reduce((current, key) => current?.#shared?.[key] instanceof NavigationState ? current.#shared[key] : null, unfrozen)
		if (scope_to_change != null) {
			if (typeof array_or_string_or_function === 'function') {
				if (!array_or_string_or_function(scope_to_change.#shared[address_array.at(-1)])) delete scope_to_change.#shared[address_array.at(-1)]
			} else {
				if (![array_or_string_or_function].flat().includes(scope_to_change.#shared[address_array.at(-1)])) delete scope_to_change.#shared[address_array.at(-1)]
			}
		}
		return this.#refreeze(this.#pivot(unfrozen))
	}

	private_keep_only_values(address, array_or_string_or_function) {
		const address_array = [address].flat()
		const unfrozen = this.editable()
		const object_to_change = address_array.slice(0,-1).reduce((current, key) => typeof current?.[key] === 'object' ? current[key] : null, unfrozen.#private)
		if (object_to_change != null) {
			if (typeof array_or_string_or_function === 'function') {
				if (!array_or_string_or_function(object_to_change[address_array.at(-1)])) delete object_to_change[address_array.at(-1)]
			} else {
				if (![array_or_string_or_function].flat().includes(object_to_change[address_array.at(-1)])) delete object_to_change[address_array.at(-1)]
			}
		}
		return this.#refreeze(this.#pivot(unfrozen))
	}

	keep_only_keys(param1, param2) {
		let [address_array, to_keep] = param2 != null ? [[param1].flat(), [param2].flat()] : [[], [param1].flat()]
		const unfrozen = this.editable()
		const scope_to_change = address_array.reduce((current, key) => current?.#shared?.[key] instanceof NavigationState ? current.#shared[key] : null, unfrozen)
		if (scope_to_change != null) {
			for (const key of Object.keys(scope_to_change.#shared)) {
				if (typeof to_keep === 'function') {
					if (!to_keep(key)) delete scope_to_change.#shared[key]
				} else {
					if (!to_keep.includes(key)) delete scope_to_change.#shared[key]
				}
			}
		}
		return this.#refreeze(this.#pivot(unfrozen))
	}

	private_keep_only_keys(param1, param2) {
		let [address_array, to_keep] = param2 != null ? [[param1].flat(), [param2].flat()] : [[], [param1].flat()]
		const unfrozen = this.editable()
		const object_to_change = address_array.reduce((current, key) => typeof current?.[key] === 'object' ? current[key] : null, unfrozen.#private)
		if (object_to_change != null) {
			for (const key of Object.keys(object_to_change)) {
				if (typeof to_keep === 'function') {
					if (!to_keep(key)) delete object_to_change[key]
				} else {
					if (!to_keep.includes(key)) delete object_to_change[key]
				}
			}
		}
		return this.#refreeze(this.#pivot(unfrozen))
	}

	default_to(address, string_or_object_or_function) {
		if (this.get(address) == null)  return this.set(address, typeof string_or_object_or_function === 'function' ? string_or_object_or_function() : string_or_object_or_function)
		return this
	}

	private_default_to(address, string_or_object_or_function) {
		if (this.private_get(address) == null)  return this.private_set(address, typeof string_or_object_or_function === 'function' ? string_or_object_or_function() : string_or_object_or_function)
		return this
	}

	static #merge_objects(merge_target, to_merge) {
		for (const [key, value] of Object.entries(to_merge)) {
			if (value == null) {
				delete merge_target[key]
			} else if (typeof value === 'string') {
				merge_target[key] = value
			} else if (typeof value === 'object') {
				if (typeof merge_target[key] != 'object') merge_target[key] = {}
				NavigationState.#merge_objects(merge_target[key], value)
			}
		}
		return merge_target
	}


	hrefs(options = {}) {
		return NavigationState.manager[StateManagerProtected.generate_hrefs](this, options)
	}

	url() {
		return NavigationState.manager[StateManagerProtected.generate_url](this)
	}

	navigate_to(options = {}) {
		return NavigationState.manager[StateManagerProtected.navigate_to_state](this, options)
	}


	[NavigationStateProtected.trim_empty_branches]() {
		const unfrozen = this.editable()
		for (const [key, value] of Object.entries(unfrozen.#shared)) {
			if (value instanceof NavigationState) {
				value[NavigationStateProtected.trim_empty_branches]()
				if (Object.keys(value.#shared).length == 0 && Object.keys(value.#private).length == 0) {
					delete unfrozen.#shared[key]
				}
			}
		}
		NavigationState.#trim_objects_empty_branches(unfrozen.#private)
		return this.#refreeze(this.#pivot(unfrozen))
	}

	static #trim_objects_empty_branches(obj) {
		for (const [key, value] of Object.entries(obj)) {
			if (typeof value === 'object' && value !== null) {
				NavigationState.#trim_objects_empty_branches(value)
				if (Object.keys(value).length == 0) {
					delete obj[key]
				}
			}
		}
	}

	#pivot(new_tree_member) {
		return new_tree_member.root_scope().scope(this.#address) //TODO maybe raise here if scope doesn't exist instead of creating a new one?
	}

	#refreeze(possibly_unfrozen) {
		if (Object.isFrozen(this.#shared)) possibly_unfrozen.root_scope().freeze()
		return possibly_unfrozen
	}


	freeze() {
		Object.freeze(this)
		Object.freeze(this.#shared)
		Object.values(this.#shared).forEach((value) => {
			if (value instanceof NavigationState) { value.freeze() } else { Object.freeze(value) }
		})
		NavigationState.freeze_object(this.#private)
		return this
	}

	static freeze_object(object) {
		Object.freeze(object)
		Object.values(object).forEach((value) => {
			if (typeof value === 'object') { NavigationState.freeze_object(value) } else { Object.freeze(value) }
		})
	}

	editable() {
		if (!Object.isFrozen(this.#shared)) return this
		return (new NavigationState(NavigationState.#constructor_call_token, null, []).merge(this.root_scope())).scope(this.#address)
	}

	[objectify_for_history]() {
		return {
			shared: Object.fromEntries(Object.entries(this.#shared).map(([key, value]) => [key, value?.[objectify_for_history]?.()  || value])),
			private: this.#private
		}
	}

	static [deobjectify_from_history](object, parent, address) {
		const state = new NavigationState(NavigationState.#constructor_call_token, parent, address)
		Object.entries(object.shared).forEach(([key, value]) => state.#shared[key] = (typeof value === 'object') ? NavigationState[deobjectify_from_history](value, state, address.concat([key])) : value)
		state.private_merge(object.private)
		return state.freeze()
	}

	[NavigationStateProtected.objectify]() {
		return Object.fromEntries(Object.entries(this.#shared).map(([key, value]) => [key, value?.[NavigationStateProtected.objectify]?.()  || value]))
	}

	static [NavigationStateProtected.deobjectify](object, parent, address) {
		const state = new NavigationState(NavigationState.#constructor_call_token, parent, address)
		Object.entries(object).forEach(([key, value]) => state.#shared[key] = (typeof value === 'object') ? NavigationState[NavigationStateProtected.deobjectify](value, state, address.concat([key])) : value)
		return state.freeze()
	}

	//unused in release, only useful for debugging
	// #validate(correct_parent = null) {
	// 	if (this.#parent !== correct_parent) throw "Incorrect parent"
	// 	if ((this.#parent == null) != (this.#address.length == 0)) throw `Parent ${this.#parent}, address ${this.#address.length}`
	// 	if (!!this.#parent && this.#parent.#shared[this.#address.at(-1)] !== this) throw "Parent-child relation messed up"
	// 	if ((this.#parent != null) && (Object.isFrozen(this.#parent.#shared) != Object.isFrozen(this.#shared))) throw "Inconsistently frozen tree"
	// 	for (const value of Object.values(this.#shared)) if (value instanceof NavigationState) value.validate(this)
	// 	return this
	// }

}



export class NavigationError extends Error {
	constructor(kind, message, stack=undefined) {
		super(message);
		this.kind = kind
		if (stack !== undefined) this.stack = stack
	}

	static from(error) {
		if (error instanceof NavigationError) return error
		return new NavigationError("UnexpectedError", `Unexpected error: ${error}`, error.stack)
	}
}



export function navigation_objectify(navigation_state_or_error) {
	if (navigation_state_or_error instanceof NavigationState) {
		return { type: "NavigationState", state: navigation_state_or_error[objectify_for_history]() }
	} else {
		return { type: "NavigationError", kind: navigation_state_or_error.kind, message: navigation_state_or_error.message, stack: navigation_state_or_error.stack }
	}
}

export function navigation_deobjectify(objectified_navigation_state_or_error) {
	if (objectified_navigation_state_or_error.type == "NavigationState") {
		return NavigationState[deobjectify_from_history](objectified_navigation_state_or_error.state, null, [])
	} else {
		return new NavigationError(objectified_navigation_state_or_error.kind, objectified_navigation_state_or_error.message, objectified_navigation_state_or_error.stack)
	}
}
