/**
 * personas/sidecar.mjs — reader / writer for the two C48 JSON files.
 *
 * The framework's write-surface for personas is JSON-only (D-01). This
 * module owns every read and write of:
 *
 *   - `.aperant/personas.json`      — the canonical persona envelope (D-02).
 *   - `.aperant/personas-runs.json` — discovery-run history (D-09).
 *
 * Every write goes through atomicWriteJson (R3). Every read is a defensive
 * try/catch that returns a well-typed empty envelope when the file is
 * missing or malformed — callers branch on the shape, not on exceptions.
 *
 * The three load-bearing invariants:
 *
 *   1. team_id sentinel (D-03). Framework writes `framework-local`; the app
 *      sync layer rebinds to a real team UUID on first app-run. Callers must
 *      not override this without --force.
 *   2. canonical_source guard (D-10). When the on-disk envelope has
 *      `canonical_source: 'cloud'`, `writeFresh` refuses without the
 *      `force: true` flag — the app wizard is the canonical entry point and
 *      silently wiping it is a known failure mode we refuse to ship.
 *   3. project_id is derived from `.aperant/project.json` when present, else
 *      a deterministic slug of the project dir. We never prompt — the CLI is
 *      designed to run headless (CI, Codex, remote Claude Code).
 *
 * Nothing in this module calls an LLM. The SKILL.md body hands us a shaped
 * `FrameworkPersona[]` envelope; we persist it. Tier classification, persona
 * drafting, and evidence-anchor assembly all live up a layer — here we only
 * guard the canonical-source boundary and serialize the result.
 */

import { existsSync, readFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { atomicWriteJson } from '../util/atomic-write.mjs'
import { withFileLock } from '../util/fs-lock.mjs'

/** Sentinel team_id (D-03). The app sync layer matches on this literal. */
export const FRAMEWORK_LOCAL_TEAM_ID = 'framework-local'

/** Current envelope schema version. Bumped when the shape changes. */
export const PERSONAS_ENVELOPE_VERSION = 1

/**
 * @typedef {'primary' | 'secondary' | 'tertiary'} PersonaTier
 */

/**
 * @typedef {Object} PersonaEvidenceAnchor
 * @property {'codebase' | 'project_md' | 'app_wizard'} source
 * @property {string} file
 * @property {string} [line_range]
 * @property {'high' | 'medium' | 'low'} [confidence]
 */

/**
 * @typedef {Object} Persona
 * @property {string} [id]
 * @property {string} team_id
 * @property {string} project_id
 * @property {string} ui_json
 * @property {string} [raw_markdown]
 * @property {string} [raw_json]
 * @property {'local-discovery' | 'cloud-discovery' | 'manual'} origin
 * @property {string} source_fingerprint
 * @property {'local' | 'cloud'} [canonical_source]
 * @property {number} local_modified_at
 * @property {number} created_at
 * @property {number} updated_at
 */

/**
 * @typedef {Object} PersonasEnvelope
 * @property {number} schema_version
 * @property {string} team_id
 * @property {string} project_id
 * @property {'local' | 'cloud'} [canonical_source]
 * @property {number} [updated_at]
 * @property {Persona[]} personas
 */

/**
 * @typedef {Object} DiscoveryRun
 * @property {string} id
 * @property {string} team_id
 * @property {string} project_id
 * @property {'pending' | 'running' | 'completed' | 'failed'} status
 * @property {string} [current_phase]
 * @property {string} [phase_log_json]
 * @property {number} started_at
 * @property {number} [completed_at]
 * @property {number} [candidates_found]
 * @property {string} [error_message]
 * @property {'desktop-local' | 'container' | 'manual'} triggered_by
 */

/**
 * @typedef {Object} RunsEnvelope
 * @property {number} schema_version
 * @property {DiscoveryRun[]} runs
 */

/**
 * Repo-relative paths for the two files we own, resolved against a project
 * dir. Split so callers can inspect paths without reading (e.g., CLI help
 * output) and tests can assert on the exact filenames.
 *
 * @param {string} projectDir
 */
export function personasPaths(projectDir) {
	const abs = resolve(projectDir)
	return {
		personas: join(abs, '.aperant', 'personas.json'),
		runs: join(abs, '.aperant', 'personas-runs.json'),
		projectJson: join(abs, '.aperant', 'project.json'),
	}
}

/**
 * Resolve a project_id. Preference order:
 *
 *   1. `.aperant/project.json`'s `id` field (the canonical location, written
 *      by `apt-tools init`).
 *   2. A deterministic `local:<basename>` fallback — keeps the value stable
 *      across runs when `.aperant/project.json` hasn't been created yet.
 *
 * The sync layer treats anything starting with `local:` as a sentinel worth
 * rebinding; a real UUID passes through as-is.
 *
 * @param {string} projectDir
 * @returns {string}
 */
export function resolveProjectId(projectDir) {
	const { projectJson } = personasPaths(projectDir)
	if (existsSync(projectJson)) {
		try {
			const data = JSON.parse(readFileSync(projectJson, 'utf-8'))
			if (data && typeof data.id === 'string' && data.id.length > 0) return data.id
		} catch {
			/* fall through to basename fallback */
		}
	}
	const name = basename(resolve(projectDir)) || 'unknown'
	return `local:${name}`
}

/**
 * Read `.aperant/personas.json`. Returns `null` when the file does not exist;
 * returns a best-effort envelope with `personas: []` when the file exists but
 * is malformed (so callers can still write into it).
 *
 * @param {string} projectDir
 * @returns {PersonasEnvelope | null}
 */
export function readPersonas(projectDir) {
	const { personas } = personasPaths(projectDir)
	if (!existsSync(personas)) return null
	let parsed
	try {
		parsed = JSON.parse(readFileSync(personas, 'utf-8'))
	} catch {
		return {
			schema_version: PERSONAS_ENVELOPE_VERSION,
			team_id: FRAMEWORK_LOCAL_TEAM_ID,
			project_id: resolveProjectId(projectDir),
			personas: [],
		}
	}
	if (!parsed || typeof parsed !== 'object') {
		return {
			schema_version: PERSONAS_ENVELOPE_VERSION,
			team_id: FRAMEWORK_LOCAL_TEAM_ID,
			project_id: resolveProjectId(projectDir),
			personas: [],
		}
	}
	const env = /** @type {PersonasEnvelope} */ (parsed)
	if (!Array.isArray(env.personas)) env.personas = []
	if (typeof env.schema_version !== 'number') env.schema_version = PERSONAS_ENVELOPE_VERSION
	if (typeof env.team_id !== 'string' || env.team_id.length === 0) {
		env.team_id = FRAMEWORK_LOCAL_TEAM_ID
	}
	if (typeof env.project_id !== 'string' || env.project_id.length === 0) {
		env.project_id = resolveProjectId(projectDir)
	}
	return env
}

/**
 * Read `.aperant/personas-runs.json`. Returns an empty `runs: []` envelope
 * when missing. Callers append via `recordRun` rather than writing the whole
 * file.
 *
 * @param {string} projectDir
 * @returns {RunsEnvelope}
 */
export function readRuns(projectDir) {
	const { runs } = personasPaths(projectDir)
	if (!existsSync(runs)) {
		return { schema_version: PERSONAS_ENVELOPE_VERSION, runs: [] }
	}
	try {
		const parsed = JSON.parse(readFileSync(runs, 'utf-8'))
		if (parsed && Array.isArray(parsed.runs)) {
			return {
				schema_version:
					typeof parsed.schema_version === 'number'
						? parsed.schema_version
						: PERSONAS_ENVELOPE_VERSION,
				runs: parsed.runs,
			}
		}
	} catch {
		/* fall through to empty */
	}
	return { schema_version: PERSONAS_ENVELOPE_VERSION, runs: [] }
}

/**
 * Stamp a persona with the canonical framework-side metadata (D-03). Writes
 * the sentinel team_id, resolves project_id, and fills timestamps. Callers
 * pass the minimal persona payload the SKILL.md body authored; we spread it
 * over the defaults so caller-provided fields always win.
 *
 * @param {Partial<Persona>} partial
 * @param {{ projectId: string, now?: number }} ctx
 * @returns {Persona}
 */
export function stampPersona(partial, ctx) {
	const now = ctx.now ?? Date.now()
	return {
		team_id: FRAMEWORK_LOCAL_TEAM_ID,
		project_id: ctx.projectId,
		ui_json: '{}',
		origin: 'local-discovery',
		source_fingerprint: 'framework-local:unknown',
		canonical_source: 'local',
		local_modified_at: now,
		created_at: now,
		updated_at: now,
		...partial,
	}
}

/**
 * Guard against overwriting a cloud-canonical envelope (D-10). Returns an
 * object that tells the caller whether to proceed, and if not, why.
 *
 * @param {PersonasEnvelope | null} existing
 * @param {{ force?: boolean }} [opts]
 */
export function checkCanonicalSourceGuard(existing, opts = {}) {
	if (!existing) return { allowed: true, reason: null }
	if (existing.canonical_source !== 'cloud') return { allowed: true, reason: null }
	if (opts.force) return { allowed: true, reason: 'user-forced' }
	return {
		allowed: false,
		reason:
			'existing .aperant/personas.json has canonical_source=cloud; the app wizard is the canonical entry point. Pass --force to override (will overwrite cloud-canonical personas).',
	}
}

/**
 * Write a fresh personas envelope (--fresh semantics per D-06). Enforces the
 * D-10 canonical-source guard unless `force: true`. Returns the envelope
 * that was actually written so the CLI can echo it back.
 *
 * The entire read-decide-write sequence runs inside `withFileLock` (SEC-002)
 * so concurrent CLI + app-wizard writes cannot race past the canonical_source
 * guard — the check and the write are atomic from the perspective of other
 * processes.
 *
 * @param {string} projectDir
 * @param {Persona[]} personas
 * @param {{ force?: boolean, now?: number }} [opts]
 * @returns {{ status: 'ok' | 'refused', envelope: PersonasEnvelope, guardReason: string | null }}
 */
export function writeFresh(projectDir, personas, opts = {}) {
	const { personas: path } = personasPaths(projectDir)
	return withFileLock(path, () => {
		const existing = readPersonas(projectDir)
		const guard = checkCanonicalSourceGuard(existing, { force: opts.force })
		if (!guard.allowed) {
			return {
				status: 'refused',
				envelope: existing ?? {
					schema_version: PERSONAS_ENVELOPE_VERSION,
					team_id: FRAMEWORK_LOCAL_TEAM_ID,
					project_id: resolveProjectId(projectDir),
					personas: [],
				},
				guardReason: guard.reason,
			}
		}

		const projectId = existing?.project_id ?? resolveProjectId(projectDir)
		const now = opts.now ?? Date.now()
		/** @type {PersonasEnvelope} */
		const envelope = {
			schema_version: PERSONAS_ENVELOPE_VERSION,
			team_id: FRAMEWORK_LOCAL_TEAM_ID,
			project_id: projectId,
			canonical_source: 'local',
			updated_at: now,
			personas: personas.map((p) => stampPersona(p, { projectId, now })),
		}

		atomicWriteJson(path, envelope)
		return { status: 'ok', envelope, guardReason: null }
	})
}

/**
 * Add net-new personas without touching existing ones (--extend semantics
 * per D-06). Dedupe is by lowercased `name` (pulled from ui_json when the
 * caller provides it as a JSON string) OR by `source_fingerprint` — whichever
 * the caller populated. A persona that matches an existing entry is skipped
 * silently; the returned `added` / `skipped` counts let the CLI report what
 * happened.
 *
 * Runs inside `withFileLock` (SEC-002) so the read-dedupe-write sequence is
 * atomic with respect to concurrent writers.
 *
 * @param {string} projectDir
 * @param {Persona[]} incoming
 * @param {{ now?: number }} [opts]
 */
export function writeExtend(projectDir, incoming, opts = {}) {
	const { personas: path } = personasPaths(projectDir)
	return withFileLock(path, () => {
		const existing = readPersonas(projectDir) ?? {
			schema_version: PERSONAS_ENVELOPE_VERSION,
			team_id: FRAMEWORK_LOCAL_TEAM_ID,
			project_id: resolveProjectId(projectDir),
			canonical_source: 'local',
			personas: [],
		}
		const projectId = existing.project_id
		const now = opts.now ?? Date.now()

		const existingNames = new Set()
		const existingFingerprints = new Set()
		for (const p of existing.personas) {
			const name = extractPersonaName(p)
			if (name) existingNames.add(name.toLowerCase())
			if (p.source_fingerprint) existingFingerprints.add(p.source_fingerprint)
		}

		/** @type {Persona[]} */
		const added = []
		/** @type {Persona[]} */
		const skipped = []

		for (const raw of incoming) {
			// Parse the persona name from raw before stamping — stampPersona
			// preserves ui_json unchanged (just spreads ...partial), so
			// extractPersonaName(stamped) === extractPersonaName(raw). Parse once
			// to avoid a redundant JSON.parse per persona (PRF-003).
			const name = extractPersonaName(raw)
			const stamped = stampPersona(raw, { projectId, now })
			const fingerprintHit = existingFingerprints.has(stamped.source_fingerprint)
			const nameHit = name ? existingNames.has(name.toLowerCase()) : false
			if (fingerprintHit || nameHit) {
				skipped.push(stamped)
				continue
			}
			added.push(stamped)
			if (name) existingNames.add(name.toLowerCase())
			existingFingerprints.add(stamped.source_fingerprint)
		}

		/** @type {PersonasEnvelope} */
		const envelope = {
			schema_version: PERSONAS_ENVELOPE_VERSION,
			team_id: existing.team_id,
			project_id: existing.project_id,
			canonical_source: existing.canonical_source ?? 'local',
			updated_at: now,
			personas: [...existing.personas, ...added],
		}

		atomicWriteJson(path, envelope)
		return { status: 'ok', envelope, added, skipped }
	})
}

/**
 * Import an existing personas envelope the app already wrote (--from-app
 * semantics per D-06). Returns the current envelope unchanged — the caller
 * uses this as the shape they refine on top of. We never mutate on read;
 * --from-app is just syntactic sugar for "show me the app's current view so
 * the SKILL.md body can surface it for refinement".
 *
 * @param {string} projectDir
 */
export function readFromApp(projectDir) {
	const existing = readPersonas(projectDir)
	if (!existing) {
		return {
			status: 'not-found',
			envelope: /** @type {PersonasEnvelope | null} */ (null),
			message:
				'.aperant/personas.json does not exist — run the app persona wizard first, or use --fresh',
		}
	}
	return { status: 'ok', envelope: existing, message: null }
}

/**
 * Record a discovery-run entry (D-09). Appends to `.aperant/personas-runs.json`.
 *
 * Per C48 D-09, framework/CLI-initiated runs use `triggered_by: 'manual'` as
 * the default. When `partial.triggered_by` is supplied the caller's value wins
 * — this allows app-side code (e.g. desktop-local pipeline) to record the
 * correct origin without a separate code path. (LOG-005)
 *
 * @param {string} projectDir
 * @param {Partial<DiscoveryRun> & { status: DiscoveryRun['status'] }} partial
 * @returns {DiscoveryRun}
 */
export function recordRun(projectDir, partial) {
	const now = Date.now()
	const runsEnv = readRuns(projectDir)
	const projectId = resolveProjectId(projectDir)

	/** @type {DiscoveryRun} */
	const run = {
		id: partial.id ?? `framework-${now}-${Math.random().toString(36).slice(2, 8)}`,
		team_id: partial.team_id ?? FRAMEWORK_LOCAL_TEAM_ID,
		project_id: partial.project_id ?? projectId,
		status: partial.status,
		started_at: partial.started_at ?? now,
		triggered_by: partial.triggered_by ?? 'manual',
		...(partial.current_phase ? { current_phase: partial.current_phase } : {}),
		...(partial.phase_log_json ? { phase_log_json: partial.phase_log_json } : {}),
		...(partial.completed_at ? { completed_at: partial.completed_at } : {}),
		...(typeof partial.candidates_found === 'number'
			? { candidates_found: partial.candidates_found }
			: {}),
		...(partial.error_message ? { error_message: partial.error_message } : {}),
	}

	runsEnv.runs.push(run)
	runsEnv.schema_version = PERSONAS_ENVELOPE_VERSION
	const { runs: path } = personasPaths(projectDir)
	atomicWriteJson(path, runsEnv)
	return run
}

/**
 * Pull a display name out of a persona's `ui_json`. Best-effort — if the
 * JSON is unparseable or lacks a `name` key, returns null. Used only for
 * dedupe in --extend.
 *
 * @param {Persona} p
 * @returns {string | null}
 */
function extractPersonaName(p) {
	if (!p.ui_json || typeof p.ui_json !== 'string') return null
	try {
		const parsed = JSON.parse(p.ui_json)
		if (parsed && typeof parsed.name === 'string' && parsed.name.length > 0) return parsed.name
	} catch {
		/* non-JSON ui_json is fine — caller can still dedupe by fingerprint */
	}
	return null
}
