import { NavigationStateDiff } from "./navigation_state_diff.mjs"
import { NavigationError } from './navigation_state.mjs'
import { NavigationState } from "./navigation_state.mjs"


//TODO: maybe make this configurable?
const URL_LENGTH_LIMIT=1800


export class URLParser {


	constructor(origin, mountpoint, version_marker, dynamic_pathname, expose) {
		this.origin = origin
		this.mountpoint = mountpoint
		this.mountpoint_without_slash = this.mountpoint.endsWith("/") ? this.mountpoint.slice(0, -1) : this.mountpoint
		this.expose = expose || []
		this.dynamic_pathname = dynamic_pathname
		this.new_diff_separator = dynamic_pathname ? '?' : '#'
		this.version = version_marker
	}


	static guess_mountpoint(pathname) {
		return pathname.split(/\/.\/|\/.$|\/==|\/$/, 1)[0]
	}

	static #generate_search_or_hash_diff_string(diff) {
		return diff.as_string_pairs().map(([key, value])=>
			value !== undefined ? (encodeURIComponent(key)+"="+encodeURIComponent(value)) : encodeURIComponent(key)
		).join("&")
	}

	#parse_search_or_hash_diff_string(to_parse, inherited_version) {
		if (to_parse.startsWith("==")) throw new NavigationError('URLNotShareable', `The URL is not sharable`)
		let split = to_parse.split("&")
		const [version, state_key] = (split[0].length > 0 && !split[0].includes("=")) ? split.shift().split("-", 2).concat([null]).slice(0, 2) : [inherited_version, null]
		split = split.filter((pair)=> pair.length > 0)
		if (version != this.version && split.length > 0) throw new NavigationError('UnknownUrlFormatVersion', `Url has unknown format version (${JSON.stringify(version)}), or is otherwise malformed`)
		const string_pairs = split.map((key_value_string)=>key_value_string.split('=',2).map(decodeURIComponent)).filter(([key, value])=> key.length > 0 || value != undefined)
		return { version, state_key, diff: NavigationStateDiff.from_string_pairs(string_pairs) }
	}

	#generate_version_cluster(state_key) {
		return this.version + (state_key != null && (state_key?.length || 0 > 0) ? ("/"+state_key) : "" )
	}

	#parse_pathname(path_after_mountpoint) {
		if (path_after_mountpoint.endsWith("/")) path_after_mountpoint = path_after_mountpoint.slice(0, -1)
		const segments = path_after_mountpoint.split('/')
		if (segments[0].startsWith("==")) throw new NavigationError('URLNotShareable', `The URL is not sharable`)

		let [version, state_key, after_version_cluster] = segments.length == 0 ? [null, null, []] : segments.length % 2 == 1 ? [segments[0],null,segments.slice(1)] : [segments[0], segments[1], segments.slice(2)]
		if ((version == null || version == "") && after_version_cluster.length == 0) return { version: null, state_key: null, diff: NavigationStateDiff.from_string_pairs([]) }
		if (version != this.version) throw new NavigationError('UnknownUrlFormatVersion', `Url has unknown format version (${JSON.stringify(version)}), or is otherwise malformed`)

		const key_value_pairs = Array.from({ length: Math.floor(after_version_cluster.length / 2) }, (_, i) => after_version_cluster.slice(i * 2, i * 2 + 2));
		const diff = NavigationStateDiff.from_string_pairs(key_value_pairs.map(([key, value]) =>
			[(this.expose.find(([pretty_name, _address]) => key == pretty_name)?.[1] || key), value]
		))
		return { version, state_key, diff }
	}

	// External href is the href which will be used by right&middle clicks, link sharing etc. They never carry any private values. External hrefs are either:
	// * Relative, starting with ? or #. Followed be a diff to be applied on the current base_state in the url. These get generated if the current url is suitable for applying the diff on.
	// * Absolute, starting with the mountpoint, followed by ? or # and the diff to be applied on an empty state. These get generated if the current url is not suitable for applying the diff on.
	// External hrefs get parsed by either parse_href, or parse_url.
	// Internal href is the href which will be used by the left click, direct js navigation etc. They carry private values. Relative internal hrefs get generated as long as the base state is usable.
	// Internal href is not generated if external href is identical.
	// Internal hrefs are only ever parsed by parse_href.
	generate_hrefs(internal_base_state, external_base_state, external_base_state_is_shareable, external_base_state_applied_version, new_state) {
		new_state = new_state?.root_scope()

		const internal_href_is_relative = internal_base_state != null
		const internal_diff = NavigationStateDiff.from_states(internal_href_is_relative ? internal_base_state : NavigationState.create(), new_state)
		const internal_search = URLParser.#generate_search_or_hash_diff_string(internal_diff)
		const internal_href = (internal_href_is_relative ? "" : this.mountpoint) + this.new_diff_separator + internal_search

		const external_href_is_relative = external_base_state != null && external_base_state_is_shareable && (external_base_state_applied_version == this.version || external_base_state_applied_version == null)
		const external_diff = NavigationStateDiff.from_states(external_href_is_relative ? external_base_state : NavigationState.create(), new_state.strip_private_recursively())
		const external_search = URLParser.#generate_search_or_hash_diff_string(external_diff)
		const external_href = (external_href_is_relative ? "" : this.mountpoint) + this.new_diff_separator + ((external_base_state_applied_version == null || !external_href_is_relative) & external_search.length > 0 ? (this.version+"&") : "") + external_search

		const hrefs = {}
		if (//!(external_search.length == 0 && internal_diff.is_private) &&
			(this.origin.length + this.mountpoint.length + external_search.length) < URL_LENGTH_LIMIT) hrefs["href"] = external_href
		if (hrefs["href"] == null || !(internal_search == external_search && internal_href_is_relative == external_href_is_relative)) hrefs["data-internal-href"] = internal_href

		return hrefs
	}


	// parse_href is ONLY used for parsing hrefs generated internally, in #navigate_to_href method. it's never used for parsing any hrefs coming from outside.
	// Allowed HREFs:
	// * relative to the current state: (?|#)[VERSION&DIFF]
	// * absolute: MOUNTPOINT(?|#)[VERSION&DIFF]
	parse_href(href) {
		const [_all, path, _search_with_question_mark, search, _hash_with_hash_mark, hash] = href.match(/^([^#?]*)(\?([^#]*))?(#(.*))?$/)
		const parsed_search = this.#parse_search_or_hash_diff_string(search || "", this.version)  //version can be given here because we wouldn't have generated any other version
		const parsed_hash = this.#parse_search_or_hash_diff_string(hash || "", parsed_search.version)
		const diff = parsed_search.diff.stack(parsed_hash.diff)
		if (path.length > 0 && path != this.mountpoint) throw new NavigationError("WrongHREFStart", `HREF doesn't start with '?', nor the mountpoint. HREF: ${href}`)
		return { relative: path.length == 0, diff }
	}


	// Allowed URLs:
	// ORIGIN/MOUNTPOINT/[VERSION[-STATE_KEY]][/DIFF][?[VERSION[-STATE_KEY]&]SEARCH_DIFF][#[VERSION&]HASH_DIFF]
	// If mountpoint ends with slash then in parsed pathname slash after mountpoint is required, otherwise it's optional at absence of state key and applied diff.
	parse_url(url) {
		url = (url instanceof URL) ? url : new URL(url)
		if (url.origin != this.origin) throw new NavigationError('WrongURLOrigin', `Unexpected origin, expected '${this.origin}', got '${url.origin}'`)
		if (!url.pathname.startsWith(this.mountpoint)) throw new NavigationError('WrongURLMountpoint', `Pathname doesn't start with our mountpoint, expected '${this.mountpoint}' at the beginning, but path was like '${url.pathname}'`)
		//TODO: verify path in static mode
		//if (url.pathname != this.mountpoint && !(!this.mountpoint.endsWith("/") && (url.pathname == this.mountpoint+"/")) ) throw new NavigationError('WrongURLMountpoint', `Path doesn't start with our mountpoint, expected '${this.mountpoint}' at the beginning, but path was like '${url.pathname}'`)
		if (!this.mountpoint.endsWith("/") && url.pathname[this.mountpoint.length] != '/' && url.pathname[this.mountpoint.length] != null) throw new NavigationError('WrongURLMountpoint', `Pathname doesn't start with our mountpoint, expected '${this.mountpoint}' at the beginning, but path starts with more than that: '${url.pathname}'`)
		const path_after_mountpoint = url.pathname.slice(this.mountpoint.length + (this.mountpoint.endsWith("/") ? 0 : 1))

		if (this.dynamic_pathname) {
			const parsed_pathname = this.#parse_pathname(path_after_mountpoint)
			const parsed_search = this.#parse_search_or_hash_diff_string(url.search.slice(1), parsed_pathname.version)
			if (parsed_search.state_key != null) throw new NavigationError("DuplicateStateKey", `Found 2 state keys in one URL. That's not yet supported.`)
			return { key: parsed_pathname.state_key, diff: parsed_pathname.diff.stack(parsed_search.diff), base_diff: parsed_pathname.diff, version: parsed_pathname.version }
		} else {
			const parsed_search = this.#parse_search_or_hash_diff_string(url.search.slice(1), null)
			const parsed_hash = this.#parse_search_or_hash_diff_string(url.hash.slice(1), parsed_search.version)
			if (parsed_hash.state_key != null) throw new NavigationError("DuplicateStateKey", `Found 2 state keys in one URL. That's not yet supported.`)
			return { key: parsed_search.state_key, diff: parsed_search.diff.stack(parsed_hash.diff), base_diff: parsed_search.diff, version: parsed_search.version }
		}
	}


	//TODO: ensure this never throws
	//Produces URLs to be shown in the location bar - and therefore - ignores the private state
	generate_url(base_state_key, base_state_with_private, state_with_private, include_origin) {
		if (state_with_private instanceof NavigationError) {
			const href = (include_origin ? this.origin : "") + this.mountpoint + (this.dynamic_pathname ? "/" : "?") + `==ERROR-${state_with_private.kind}`
			return { url: href, is_perfect: true, is_shareable: false, href, url_version: "h", base_state: state_with_private }
		}
		const base_state_without_private = base_state_with_private.root_scope().strip_private_recursively()
		const state_without_private = state_with_private.root_scope().strip_private_recursively()
		const pretty_diff = NavigationStateDiff.exposed_diff(state_without_private, this.expose.map(([_pretty_name, address])=>address))
		const pretty_diff_pairs = pretty_diff.as_string_pairs().reduce((hash, [address, value]) => ({ ...hash, [address]: value}), {})
		const pretty_pairs = this.expose.map(([pretty_name, address])=>
			pretty_diff_pairs[address] == null ? null : [pretty_name, pretty_diff_pairs[address]]
		).filter(pair=> pair!=null)
		const diff_pairs = NavigationStateDiff.from_states(pretty_diff.patch(base_state_without_private), state_without_private).as_string_pairs()
		const all_pairs = pretty_pairs.concat(diff_pairs)

		const href = (include_origin ? this.origin : "") + this.mountpoint + ((all_pairs.length == 0 && base_state_key == undefined) ? "" : (this.dynamic_pathname ?
			((this.mountpoint.endsWith("/") ? "" : "/") + this.#generate_version_cluster(base_state_key) + '/' + all_pairs.map(([key, value]) => encodeURIComponent(key) + "/" + encodeURIComponent(value)).join("/"))
			:
			("?" + this.#generate_version_cluster(base_state_key) + (all_pairs.length > 0 ? '&' : "") + all_pairs.map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)).join("&"))))

		const is_shareable = href.length + (include_origin ? 0 : this.origin.length) <= URL_LENGTH_LIMIT
		return { url: !is_shareable ? ((include_origin ? this.origin : "") + this.mountpoint + (this.dynamic_pathname ? "/" : "?") + `==NOT-SHAREABLE`) : href,  is_perfect: diff_pairs.length == 0, is_shareable, href, url_version: this.version, base_state: state_without_private }
	}


	state_to_save(state_with_private) {
		const state_without_private = state_with_private.root_scope().strip_private_recursively()
		const pretty_diff = NavigationStateDiff.exposed_diff(state_without_private, this.expose.map(([_pretty_name, address])=>address))
		const pretty_diff_inverted = NavigationStateDiff.from_string_pairs(pretty_diff.as_string_pairs().map(([address, _value]) => [address, '~']))
		return pretty_diff_inverted.patch(state_without_private)
	}

}
