// Run the interactive review script.
// You might want to override the EDITOR environment variable to your favorite editor or IDE script
// Reads the data generated by import-hooks script.
// Keeps the state in reviewed.json
import fs from "node:fs/promises"
import path from "node:path"
import readline from "node:readline/promises"
import { fileURLToPath, pathToFileURL } from "node:url"
import { $ } from "zx"
import { createHash } from "node:crypto"

const data = JSON.parse(await fs.readFile(process.argv[2], { encoding: "utf8" }))

const rl = readline.createInterface(process.stdin, process.stdout)
const deps = data[undefined]

const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const reviewedPath = path.join(scriptDir, "reviewed.json")

/** @type {Record<string, {who: string, when: string, hash: string}>} */
let reviewedData = {}
try {
	reviewedData = JSON.parse(await fs.readFile(reviewedPath, { encoding: "utf8" }))
} catch (e) {
	console.log(`Could not read reviewed data from ${reviewedPath}`)
}

/** @type {Map<string, string>} */
const reviewedHashes = new Set(Object.values(reviewedData).map(({ hash }) => hash))

let reviewers = Object.values(reviewedData).at(-1)?.who
const reviewersAnswer = await rl.question(`who is reviewing: (${reviewers})`)
if (reviewersAnswer.trim() !== "") {
	reviewers = reviewersAnswer
}

async function hashFileAt(filePath) {
	const fileContents = await fs.readFile(filePath, { encoding: "utf-8" })
	const hasher = createHash("sha256")
	hasher.update(fileContents)
	return hasher.digest("hex")
}

async function markAsReviewed(currentDep) {
	reviewedData[currentDep] = {
		who: reviewers,
		when: new Date().toISOString().slice(0, 10),
		hash: await hashFileAt(currentDep),
	}
	await fs.writeFile(reviewedPath, JSON.stringify(reviewedData, null, 4), { encoding: "utf8" })
}

const fileHashes = await calculateFileHashes("entry", deps)

async function review(currentDep, itsDeps, backtrace) {
	uiloop: while (true) {
		console.log(`\nReviewing ${currentDep}`)
		const isEntry = !backtrace.length
		if (!isEntry) {
			console.log(`at ${backtrace.join(", ")}`)
			console.log(pathToFileURL(currentDep).href)
		}

		const actions = []
		actions.push({
			char: "d",
			msg: "Mark as reviewed",
			finalizes: true,
			action: async () => {
				console.log(`Marking ${currentDep} as reviewed`)
				await markAsReviewed(currentDep)
			},
		})
		actions.push({
			char: "x",
			msg: "Go up",
			finalizes: true,
			action: () => {},
		})
		if (!isEntry) {
			const editor = process.env["EDITOR"] ?? "vi"
			actions.push({
				char: "o",
				msg: `Open with ${editor}`,
				action: async () => {
					$.stdio = "inherit"
					await $`${editor} ${currentDep}`
				},
			})
		}

		if (typeof itsDeps === "string") {
			console.log("CYCLE: ", itsDeps)
		} else {
			const depsArray = Object.entries(itsDeps)
			const depsStats = new Map()

			for (const [i, [key, value]] of depsArray.entries()) {
				const mark = key.startsWith("node:") || countsAsReviewed(key) ? "✅" : " "
				const stats = calculateStats(key, value)
				depsStats.set(key, stats)
				actions.push({
					char: String(i),
					msg: `${mark} ${String(stats.reviewed).padStart(4)}/${String(stats.overall).padStart(4)} ${key}`,
					action: async () => {
						const numAnswer = parseInt(answer)
						if (!isNaN(numAnswer) && numAnswer < depsArray.length) {
							const [dep, itsDeps] = depsArray[numAnswer]
							await review(dep, itsDeps, [...backtrace, currentDep])
						}
					},
				})
			}

			const nextUnreviewed = depsArray.find(([dep, _]) => !countsAsReviewed(dep))
			const next =
				nextUnreviewed ??
				depsArray.find(([dep]) => {
					const stat = depsStats.get(dep)
					return stat != null && stat.reviewed < stat.overall
				})
			if (next) {
				actions.push({
					char: "n",
					msg: `next unreviewed ${next[0]}`,
					action: () => review(next[0], next[1], [...backtrace, currentDep]),
				})
			}
		}
		for (const action of actions) {
			console.log(`${action.char}: ${action.msg}`)
		}
		const answer = await rl.question("What to review?: ")
		for (const action of actions) {
			if (answer === action.char) {
				await action.action()
				if (action.finalizes) {
					break uiloop
				}
			}
		}
	}
}

function transitiveDeps(currentDep, itsDeps) {
	if (isBuiltin(currentDep)) {
		return []
	}
	const collectedDeps = Object.entries(itsDeps)
		.map(([dep, depDeps]) => {
			if (typeof depDeps === "string") {
				return []
			}
			return transitiveDeps(dep, depDeps, [currentDep])
		})
		.flat()
	return [currentDep, ...collectedDeps]
}

function isExplicitlyReviewed(dep) {
	return Object.hasOwn(reviewedData, dep)
}

function isBuiltin(dep) {
	return dep.startsWith("node:")
}

function countsAsReviewed(dep) {
	return isExplicitlyReviewed(dep) || isBuiltin(dep) || reviewedByHash(dep)
}

function reviewedByHash(dep) {
	const fileHash = fileHashes.get(dep)
	reviewedHashes.has(fileHash)
}

function calculateStats(currentDep, itsDeps) {
	const collectedTransitiveDeps = transitiveDeps(currentDep, itsDeps)
	const dedupedDeps = new Set(collectedTransitiveDeps)
	let reviewed = 0
	for (const dep of dedupedDeps) {
		if (isExplicitlyReviewed(dep) || reviewedByHash(dep)) {
			reviewed += 1
		}
	}
	return { reviewed, overall: dedupedDeps.size }
}

/**
 * @param entry {string}
 * @param deps
 * @returns {Promise<Map<string, string>>}
 */
async function calculateFileHashes(entry, deps) {
	const collectedTransitiveDeps = transitiveDeps(entry, deps)
	const dedupedDeps = new Set(collectedTransitiveDeps)
	const hashes = new Map()
	for (const dep of dedupedDeps) {
		try {
			const hash = await hashFileAt(dep)
			hashes.set(dep, hash)
		} catch (e) {
			console.log("could not hash", dep)
		}
	}
	return hashes
}

await review("entry", deps, [])
