import type { Thread } from '@wovin/core'
import type { ApplogForInsert } from '@wovin/core/applog'
import type { IShare, ISubscription, AppAgent as WovinAppAgent } from '@wovin/core/pubsub'
import type { GenericObject } from '@wovin/core/types'
import type { CryptoKeys } from './AgentCrypto'
import { agentToShortHash, filterAndMap, lastWriteWins, rollingFilter } from '@wovin/core'

import { action, computed, flow, makeObservable, observable } from '@wovin/core/mobx'
import { arrayBufferToBase64, DefaultFalse } from '@wovin/utils'
import { fnBrowserDetect } from '@wovin/utils/browser'
import { Logger } from 'besonders-logger'
import { getHKDFkeyFromHashArray } from 'mnemkey'
import stringify from 'safe-stable-stringify'
import { Mixin } from 'ts-mixer'
import { stopPropagation } from '../../ui/utils-ui'
import { getApplogDB, insertApplogsInAppDB } from '../ApplogDB'
import {

	deriveSecretBitsFromECDH,
	getAESkeyPersonal,
	getDerivationKeypairFromIDB,
	getEdDSAfromIDB,
	initializeCryptoKeypairs,
} from './AgentCrypto'
import { AgentPubSub } from './AgentPubSub'

import { AgentStorage } from './AgentStorage'

const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars

export const DEMO_USERNAME = 'demouser'
export class AgentStateClass extends Mixin(AgentPubSub, AgentStorage) implements WovinAppAgent {
	app = 'note3'
	username = localStorage.getItem('agent.username') ?? DEMO_USERNAME
	devicename = localStorage.getItem('agent.devicename') ?? 'device'
	agentcode = localStorage.getItem('agent.agentcode') ?? `${import.meta.env.DEV ? 'dev-' : ''}${fnBrowserDetect() ?? 'browser'}`

	crypto: CryptoKeys = null
	w3NamePublic: string | null = null // needs to be set before getters will work

	_cryptoDerivationPublicJWK?: globalThis.JsonWebKey
	_cryptoDerivationPublicECDH?: string // extracted from the IDB keystore on first request then cached in memory here

	loadIDB = async () => {
		// TODO: dexie doesn't support nullable index/sorting - so do it manually
		this.reloadPubSub()
		this.loading = false
	}

	// TODO figure out how to move these getters to AgentPubSub and make them mobx computed
	// get publicationsMap() {
	// 	return new Map(this.publications.map(pub => [pub.id, pub]))
	// }
	// get subscriptionsMap() {
	// 	return new Map(this.subscriptions.map(sub => [sub.id, sub]))
	// }
	// get mostRecentPublication() {
	// 	if (!this.publications.length) return null
	// 	return this.publications[this.publications.length - 1]
	// }

	getHKDFkey = async (keypair?: CryptoKeyPair) => {
		if (!this.crypto?.hkdf) {
			const personalECDH = await this.getDerivationKeypair()
			this.crypto.hkdf = await getHKDFkeyFromHashArray(await deriveSecretBitsFromECDH(personalECDH))
		}
		return this.crypto?.hkdf
	}

	getPersonalEncryptionKey = async () => {
		if (!this.crypto?.personalAes) this.crypto.personalAes = await getAESkeyPersonal()
		return this.crypto?.personalAes
	}

	getReliableDid = async () => {
		return (await this.getEdDSAsigningKeypair()).did
	}

	get shortDID() {
		return this.did?.slice(-8) || 'noDIDyet' // (i) first 24 from did "always" seem to be did:key:z4MXj1wBzi9jUsty
	}

	get isAgentStringSetup() {
		return localStorage.getItem('agent.isSetManually')
	}

	isAgentStringMatching = computed(() => getAgentString(this.ag) === this.agentString)
	get expectedAgentHash() {
		return localStorage.getItem('agent.expectedAgentHash') ?? ''
	}

	set expectedAgentHash(hashString) {
		localStorage.setItem('agent.expectedAgentHash', hashString)
	}

	ensureAgentAtoms = flow(function* (this: AgentStateClass) {
		if (!this.crypto?.ecdh) yield this.getDerivationKeypair()
		if (!this.crypto?.ecdh) return WARN('[ensureAgentAtoms] not possible without ecdh', this.crypto)
		if (!this.crypto.eddsa) yield this.getEdDSAsigningKeypair()
		if (!this.crypto.eddsa) return WARN('[ensureAgentAtoms] not possible without eddsa', this.crypto)
		if (!this.did) return WARN('[ensureAgentAtoms] not possible without did')
		const { agentString, ag } = this
		DEBUG(`[agentAtoms] current agent:`, { ag, agentString })
		let ds: Thread
		try {
			ds = getApplogDB()
		} catch (err) {
			return WARN('[ensureAgentAtoms] not possible without db', err)
		}
		const agentAtoms = [] as ApplogForInsert[]

		// const appAgentResult = db.enatIndex[`${ag}_|_agent/appAgent`]?.[0] // as ag is a unique hash, this should always be the same

		// const deriverLog = rollingFilter(ds, { en: ag, at: 'agent/jwkd' })
		let deriverJWKD = filterAndMap(lastWriteWins(ds), { en: ag, at: 'agent/jwkd' }, 'vl')[0]
		let deriverECDH64 = filterAndMap(lastWriteWins(ds), { en: ag, at: 'agent/ecdh' }, 'vl')[0]
		const appAgentFromLogs = getAgentString(ag)
		if (appAgentFromLogs === agentString) {
			VERBOSE('[agentAtoms] matching appAgent log exists for current ag, assuming its fine', { appAgentFromLogs })
		} else {
			WARN('[agentAtoms] appAgentLogs missing or non matching', {
				appAgentFromLogs,
				deriverECDH64,
				deriverJWKD,
			})

			agentAtoms.push(
				{ en: ag, at: 'agent/appAgent', vl: agentString, ag },
			)
		}
		// array of all atoms for this specific agent - can be more than one if the key has been "rotated"
		if (deriverJWKD && deriverECDH64) {
			VERBOSE('[agentAtoms] deriver jwk and 64 both exist, assuming its fine', { deriverECDH64, deriverJWKD })
			// TODO stop assuming and start checking
			// if (deriverJWKD.size > 1) WARN('//TODO deal with multiple jwkd atoms')
		} else {
			if (!deriverECDH64) {
				const newDeriverECDH = deriverECDH64 = yield this.getPublicDerivationECDH()
				WARN('[agentAtoms] ECDH deriverLog missing or non matching', { newDeriverECDH })
				agentAtoms.push({ en: ag, at: 'agent/ecdh', vl: newDeriverECDH, ag })
			}
			if (!deriverJWKD) {
				const newDeriverJWK = deriverJWKD = yield this.getDerivationJWK()
				WARN('[agentAtoms] ECDH deriverJWKLog missing or non matching', { newDeriverJWK })
				agentAtoms.push({ en: ag, at: 'agent/jwkd', vl: stringify(newDeriverJWK), ag })
			}
		}

		const nonExistingAgentAtoms = agentAtoms.filter(a => !ds.hasApplogWithDiffTs(a))
		if (nonExistingAgentAtoms.length) {
			VERBOSE('[agentAtoms] adding', ag, { agentAtoms, nonExistingAgentAtoms }) // double check that the above did not produce any duplicates
			this.expectedAgentHash = ag // update expected if anything changed
			yield insertApplogsInAppDB(nonExistingAgentAtoms)
		} else {
			DEBUG('[agentAtoms] alles klar miene shatzie', { deriverJWKD, deriverECDH64, appAgentFromLogs })
		}
	})

	// TODO: mobx-ify those:
	getDerivationJWK = async () => {
		if (!this._cryptoDerivationPublicJWK) {
			this._cryptoDerivationPublicJWK = await globalThis.crypto.subtle.exportKey('jwk', (await this.getDerivationKeypair()).publicKey)
		}
		return this._cryptoDerivationPublicJWK
	}

	getPublicDerivationECDH = async () => {
		if (!this._cryptoDerivationPublicECDH) {
			const exportedKey = await globalThis.crypto.subtle.exportKey('raw', (await this.getDerivationKeypair()).publicKey)
			const keyBase64 = arrayBufferToBase64(exportedKey)
			DEBUG('sharedKey', { exportedKey, keyBase64 })
			this._cryptoDerivationPublicECDH = keyBase64
		}
		return this._cryptoDerivationPublicECDH
	}

	getPublicDerivationECDHshortSync = () => {
		if (!this._cryptoDerivationPublicECDH) {
			this.getPublicDerivationECDH()
			return
		}
		return `${this._cryptoDerivationPublicECDH.slice(0, 4)}...${this._cryptoDerivationPublicECDH.slice(-4)}`
	}

	getSharedKeyApplogForCurrentAgent = (infoLogs) => {
		DEBUG('looking for sharedKeyLog for ', this.ag, 'in', { infoLogs })
		return infoLogs.find(eachLog => (
			(eachLog?.at as string) === 'pub/sharedKey'
			&& (eachLog?.en as string) === this.ag
		))
	}

	getEdDSAsigningKeypair = async (newMnemonic?: string[]) => {
		if (newMnemonic) {
			throw ERROR('//TODO newMnemonic')
		} else {
			if (this.crypto.eddsa) return this.crypto.eddsa // -commented to use new lib //? bring back

			return getEdDSAfromIDB(this)
		}
	}

	getDerivationKeypair = async (newMnemonic?: string[]) => {
		if (newMnemonic || !this.crypto?.ecdh) {
			if (!this.crypto) throw new Error('getDerivationKeypair called before agent.crypto init')
			this.crypto.ecdh = newMnemonic
				? await initializeCryptoKeypairs(this, newMnemonic) // reinitialize with newMnem
				: await getDerivationKeypairFromIDB(this) // try to get from IDB
					?? await initializeCryptoKeypairs(this) // create new
		}
		return this.crypto.ecdh as CryptoKeyPair
	}

	getKnownAgents(thread: Thread = getApplogDB()) {
		const knownAgentPKlogs = rollingFilter(lastWriteWins(thread), { at: 'agent/jwkd' })
		const knownAgentECDHlogs = rollingFilter(lastWriteWins(thread), { at: 'agent/ecdh' })

		return computed(() => {
			const mappedAgentLogs = knownAgentPKlogs.applogs.map(
				(log, _logIdx) => {
					const { en: eachAg, vl: jwkd } = log
					const agString = getAgentString(eachAg)
					return [eachAg, { log, ag: eachAg, agString, jwkd }] as const
				},
			)
			const agentsMap = new Map(mappedAgentLogs) // TODO ts
			DEBUG('agentMapMemo', { mappedAgentLogs, agentsMap, knownAgentECDHlogs, knownAgentPKlogs })
			return agentsMap
		})
	}

	signWithEdDSA = async (message: Uint8Array) => {
		return (await this.getEdDSAsigningKeypair()).sign(message)
	}

	signWithEdDSAFromIDB = async (message: Uint8Array) => {
		return (await getEdDSAfromIDB(this)).sign(message)
	}

	getProxyfiableSignFx = async () => {
		return (await getEdDSAfromIDB(this)).sign
	}

	/**
	 * satisfying @wovin/core/AppAgent::
	 * did, ag, agentString, sign
	 */
	get did() {
		if (this.crypto?.eddsa) {
			return this.crypto?.eddsa?.did
		}
		throw ERROR('Missing DID')
	}

	get ag() {
		return agentToShortHash(this.agentString)
	}

	get agentString() {
		return `${this.username}.${this.shortDID}@${this.app}.${this.agentcode}.${this.devicename}`
	}

	get agentStringNoDID() {
		return `${this.username}@${this.app}.${this.agentcode}.${this.devicename}`
	}

	sign = this.signWithEdDSA
	/* constructor() {
		// ! moved mobx init after constructor to enable code separation via mixin (eg. AgentPubSub)
	} */
	loading = true
}

const agentState = new AgentStateClass() // not exported to force use of useAgent()
export function useAgent() {
	return agentState
}
makeObservable(agentState, {
	loading: observable,
	app: observable,
	username: observable,
	devicename: observable,
	agentcode: observable,
	crypto: observable.ref,
	w3NamePublic: observable,

	sharesMap: computed,
	subscriptionsMap: computed,
	// hasStorageSetup: computed,
	mostRecentShare: computed,
	did: computed,
	shortDID: computed,
	agentString: computed,
	ag: computed,
	// addPub: action,
	// addSub: action,
	// publications: observable, - already observable.array
	// subscriptions: observable,
})
export const updateAgentState = action((newState: Partial<AgentStateClass>) => {
	// TODO persist changes to IDB

	DEBUG('[AgentState] before', { agentState: JSON.parse(stringify(agentState)), newState })
	Object.assign(agentState, newState)
	DEBUG('[AgentState] after', JSON.parse(stringify(agentState)))
})

export function getAgentString(agentEntityHash) {
	const values = filterAndMap(lastWriteWins(getApplogDB()), { en: agentEntityHash, at: 'agent/appAgent' }, 'vl')
	if (values.length > 1) WARN(`Multiple appAgent atoms`, values)
	VERBOSE(`AppAgent from DB`, values[0])
	return values[0]
}
// (getApplogDB().getSortedApplogsFromIndex({ whichID: `${agentEntityHash}_|_agent/appAgent`, whichIndex: 'enatIndex' }))?.[0]?.vl
/******
 * agentString =  ${username}.${pubkey} @ ${agentcode}.${devicename}
 * agentString =  ${w3ui-verifiedEmailAddress}.${fullDID} @ ${agentcode}.${devicename}

	agentString =  ${userhandle}.${shortDID} @ ${app}.${agentcode}.${devicename}
	agentStringEx =  	myuser.PVZxrKoX@dev.note3.chromium.laptop
	ag = hash(agentString)// used for every appLog

	atoms needed
	ag=>did mapping
	ag=>agentstring
	ag=>signed agentsring

	// ??
	ag=>ucan mapping
	ag=>exported json public key // only if did extraction

********/
const syncToLocalStorageDefaults = ['username', 'devicename', 'agentcode', 'isSetManually']
export const valuesAsofBinding = new Map()
const acceptableExtraAttr: { shareID?: string, pubID?: string, subID?: string, padding?: string, extraClasses?: string } = {}
export function boundInput(
	prop: keyof AgentStateClass,
	syncToLocalStorage = syncToLocalStorageDefaults,
	extraAttrs = acceptableExtraAttr,
	onChange?: (newVal: string) => void,
	skipBlur = DefaultFalse,
) {
	const pubOrSubID = extraAttrs.shareID || extraAttrs.pubID || extraAttrs.subID
	const val = pubOrSubID
		? (agentState[prop] as Map<string, GenericObject>)?.get(pubOrSubID as string)?.name ?? 'unknown'
		: agentState[prop]
	// VERBOSE('[agent] binding', { [prop]: val })
	valuesAsofBinding.set(prop, val)
	// value="${username}" onkeydown="${handleKeydown}" oninput="${setContents}
	const onkeydown = (evt: any) => {
		// const { agentstring: agentStringDirect } = state()
		DEBUG('[onkeydown]', { val, evt })
		const newVal = evt.target.textContent
		onChange?.(newVal)
	}
	const oninput = (evt: any) => {
		// const newVal = evt.target.textContent
		// // host[prop] = val - breaks UX
		// store.set(AgentState, { [prop]: val })
		// DEBUG('[oninput]', val, { host, evt })
		// if (['username', 'devicename'].includes(prop)) {
		// 	localStorage.setItem(`agent.${prop}`, val)
		// }
	}
	const onblur = (evt: any) => {
		const newVal = evt.target.textContent
		// VERBOSE('[onblur]', prop, 'newVal:', newVal)
		if (newVal !== val) {
			if (pubOrSubID && !skipBlur) {
				if (!['sharesMap', 'subscriptionsMap'].includes(prop)) throw new Error('invalid prop for pubsub')
				const currentPubOrSub: ISubscription | IShare
					= agentState[prop as 'sharesMap' | 'subscriptionsMap'] /* as Map<string, GenericObject> */?.get(
						pubOrSubID as string,
					)
				DEBUG('[onblur] with subPath', prop, { currentPubOrSub, newVal })
				if (!currentPubOrSub) throw ERROR(`Invalid pubOrSubID`, pubOrSubID)
				if (prop === 'sharesMap') {
					agentState.updateShare(currentPubOrSub.id, { name: newVal })
				} else {
					agentState.updateSub(currentPubOrSub.id, { name: newVal })
				}
			} else if (!skipBlur) {
				const newState = updateAgentState({ [prop]: newVal })
				VERBOSE('[onblur]', prop, 'newState:', newState)

				if (syncToLocalStorage.includes(prop)) {
					localStorage.setItem(`agent.isSetManually`, 'true')
					localStorage.setItem(`agent.${prop}`, newVal)
				}
			} else {
				VERBOSE('not pub or sub likely skipBlur ', { skipBlur })
			}
		}
	}

	// TODO grok how to penetrate shadow dom situation
	return (
		<span
			w-fit
			focus-outline-none
			rounded-sm
			b-b='2 solid border hover:blue focus:blue-600'
			bg='border opacity-10 hover:opacity-15'
			leading-loose
			hover-bg-white-200
			focus-bg-white-100
			role='textbox'
			contenteditable
			spellcheck={false}
			class={[extraAttrs.padding ?? 'py-0.5 px-[0.15rem]', extraAttrs.extraClasses].join(' ')}
			style={{ 'margin-bottom': 'calc(-0.125rem - 2px)' }}
			{...{ onblur, onkeydown, oninput, onclick: stopPropagation }}
		>
			{val}
		</span>
	)
}
