/**
 * Shared source-config form model for the four workload Source kinds
 * (image / compose / static / dockerfile).
 *
 * Before this module the seed (JSON -> form fields) and serialize
 * (form fields -> source_config JSON) logic lived inline and DUPLICATED
 * verbatim in both `routes/apps/new/+page.svelte` and
 * `routes/apps/[id]/+page.svelte`. A drift between the two silently
 * changes the `source_config` shape the backend stores, which breaks
 * deploys. This module is the single source of truth so the create
 * wizard and the detail-page edit form serialize identically.
 *
 * The functions are pure (no Svelte runes, no DOM) so they unit-test in
 * a plain node environment. Components hold the state objects as `$state`
 * and call these to seed / serialize.
 *
 * Fidelity contract: output key order, defaults, and the preserve/scrub
 * behaviour below MUST match the legacy inline helpers exactly. Tests in
 * `sourceForms.test.ts` lock the shapes.
 */

export type GitProvider = 'gitea' | 'github' | 'gitlab';

/**
 * Per-workload deploy strategy.
 *
 * `''` means "use the source's historical default" (image → blue-green,
 * dockerfile / static → recreate). It is the canonical value we persist
 * whenever the operator has NOT deviated from that default, so existing
 * `source_config` blobs stay byte-identical and the key is only ever
 * written when it actually differs. The backend's `effectiveStrategy()`
 * resolves `''` to the same per-source default, so the two are equivalent.
 */
export type DeployStrategy = '' | 'recreate' | 'blue-green';

/** Image source: deploy a pre-built image from a registry. */
export interface ImageFormState {
	ref: string;
	port: number;
	healthcheck: string;
	defaultTag: string;
	registryName: string;
	cpuLimit: number;
	memoryLimit: number;
	maxInstances: number;
	/** "" = source default (blue-green for image); else explicit. */
	deployStrategy: DeployStrategy;
}

/** Compose source: a docker-compose stack. */
export interface ComposeFormState {
	yaml: string;
	projectName: string;
}

/**
 * Git-discovery fields shared by the static and dockerfile sources —
 * both clone a repo via the same provider/owner/repo/branch/token path.
 * Extracted so a single discovery component can bind this slice of
 * either form.
 */
export interface GitSourceState {
	provider: GitProvider;
	baseURL: string;
	repoOwner: string;
	repoName: string;
	branch: string;
	accessToken: string;
}

/** Static source: serve files (optionally Deno) from a repo folder. */
export interface StaticFormState extends GitSourceState {
	folderPath: string;
	mode: 'static' | 'deno';
	renderMarkdown: boolean;
	/** Report deploy outcome back to the git provider as a commit status. */
	reportCommitStatus: boolean;
	/** "" = source default (recreate for static); else explicit. */
	deployStrategy: DeployStrategy;
}

/** Dockerfile source: build an image from a Dockerfile in a repo. */
export interface DockerfileFormState extends GitSourceState {
	contextPath: string;
	dockerfilePath: string;
	port: number;
	/** Report deploy outcome back to the git provider as a commit status. */
	reportCommitStatus: boolean;
	/** "" = source default (recreate for dockerfile); else explicit. */
	deployStrategy: DeployStrategy;
}

// ── Defaults ────────────────────────────────────────────────────────

export function emptyImageState(): ImageFormState {
	return {
		ref: '',
		port: 0,
		healthcheck: '',
		defaultTag: 'latest',
		registryName: '',
		cpuLimit: 0,
		memoryLimit: 0,
		maxInstances: 1,
		deployStrategy: ''
	};
}

export function emptyComposeState(): ComposeFormState {
	return { yaml: '', projectName: '' };
}

function emptyGitSourceState(): GitSourceState {
	return {
		provider: 'gitea',
		baseURL: '',
		repoOwner: '',
		repoName: '',
		branch: 'main',
		accessToken: ''
	};
}

export function emptyStaticState(): StaticFormState {
	return {
		...emptyGitSourceState(),
		folderPath: '',
		mode: 'static',
		renderMarkdown: false,
		reportCommitStatus: false,
		deployStrategy: ''
	};
}

export function emptyDockerfileState(): DockerfileFormState {
	return {
		...emptyGitSourceState(),
		contextPath: '',
		dockerfilePath: 'Dockerfile',
		port: 0,
		reportCommitStatus: false,
		deployStrategy: ''
	};
}

// ── Parse helpers ───────────────────────────────────────────────────

/** Parse to an object for seeding; malformed / non-object JSON -> {}. */
function parseObject(jsonText: string): Record<string, unknown> {
	try {
		const parsed: unknown = JSON.parse(jsonText);
		if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
			return parsed as Record<string, unknown>;
		}
	} catch {
		// fall through
	}
	return {};
}

/** Parse for preserve helpers; malformed JSON -> null (caller guards). */
function tryParse(jsonText: string): Record<string, unknown> | null {
	try {
		const parsed: unknown = JSON.parse(jsonText);
		if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
			return parsed as Record<string, unknown>;
		}
	} catch {
		// fall through
	}
	return null;
}

function strOr(value: unknown, fallback: string): string {
	return typeof value === 'string' ? value : fallback;
}

/** Non-empty string or fallback (matches legacy `typeof x === 'string' && x ? x : d`). */
function strOrTruthy(value: unknown, fallback: string): string {
	return typeof value === 'string' && value ? value : fallback;
}

function numOr(value: unknown, fallback: number): number {
	return typeof value === 'number' ? value : fallback;
}

function normProvider(value: unknown): GitProvider {
	return value === 'github' || value === 'gitlab' ? value : 'gitea';
}

/** Recognized explicit strategies; anything else (incl. absent) -> "". */
function normStrategy(value: unknown): DeployStrategy {
	return value === 'recreate' || value === 'blue-green' ? value : '';
}

// ── Seed: source_config JSON -> form state ──────────────────────────

export function seedImageState(jsonText: string): ImageFormState {
	const o = parseObject(jsonText);
	return {
		ref: strOr(o.image, ''),
		port: numOr(o.port, 0),
		healthcheck: strOr(o.healthcheck, ''),
		defaultTag: strOr(o.default_tag, 'latest'),
		registryName: strOr(o.registry_name, ''),
		cpuLimit: numOr(o.cpu_limit, 0),
		memoryLimit: numOr(o.memory_limit, 0),
		maxInstances: numOr(o.max_instances, 1),
		deployStrategy: normStrategy(o.deploy_strategy)
	};
}

export function seedComposeState(jsonText: string): ComposeFormState {
	const o = parseObject(jsonText);
	return {
		yaml: strOr(o.compose_yaml, ''),
		projectName: strOr(o.compose_project_name, '')
	};
}

export function seedStaticState(jsonText: string): StaticFormState {
	const o = parseObject(jsonText);
	return {
		provider: normProvider(o.provider),
		baseURL: strOr(o.base_url, ''),
		repoOwner: strOr(o.repo_owner, ''),
		repoName: strOr(o.repo_name, ''),
		branch: strOrTruthy(o.branch, 'main'),
		accessToken: strOr(o.access_token, ''),
		folderPath: strOr(o.folder_path, ''),
		mode: o.mode === 'deno' ? 'deno' : 'static',
		renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
		reportCommitStatus:
			typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
		deployStrategy: normStrategy(o.deploy_strategy)
	};
}

export function seedDockerfileState(jsonText: string): DockerfileFormState {
	const o = parseObject(jsonText);
	return {
		provider: normProvider(o.provider),
		baseURL: strOr(o.base_url, ''),
		repoOwner: strOr(o.repo_owner, ''),
		repoName: strOr(o.repo_name, ''),
		branch: strOrTruthy(o.branch, 'main'),
		accessToken: strOr(o.access_token, ''),
		contextPath: strOr(o.context_path, ''),
		dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
		port: numOr(o.port, 0),
		reportCommitStatus:
			typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
		deployStrategy: normStrategy(o.deploy_strategy)
	};
}

// ── Serialize: form state -> source_config object ───────────────────

/**
 * Preserve `env` (object) and `volumes` (array) from an existing config
 * — they're edited in dedicated detail-page panels, not the source form,
 * and must survive a form round-trip.
 */
function preserveEnvVolumes(existingJson: string): {
	env: Record<string, string>;
	volumes: unknown[];
} {
	const existing = tryParse(existingJson);
	let env: Record<string, string> = {};
	let volumes: unknown[] = [];
	if (existing) {
		if (existing.env && typeof existing.env === 'object') {
			env = existing.env as Record<string, string>;
		}
		if (Array.isArray(existing.volumes)) {
			volumes = existing.volumes;
		}
	}
	return { env, volumes };
}

export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
	const { env, volumes } = preserveEnvVolumes(existingJson);
	const out: Record<string, unknown> = {
		image: s.ref,
		registry_name: s.registryName,
		port: s.port,
		healthcheck: s.healthcheck,
		env,
		volumes,
		cpu_limit: s.cpuLimit,
		memory_limit: s.memoryLimit,
		default_tag: s.defaultTag,
		max_instances: s.maxInstances
	};
	// Only written when the operator deviates from the source default, so an
	// untouched workload's config stays byte-identical (see DeployStrategy).
	if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
	return out;
}

export function composeToConfig(s: ComposeFormState): Record<string, unknown> {
	return { compose_yaml: s.yaml, compose_project_name: s.projectName };
}

export function staticToConfig(s: StaticFormState, existingJson: string): Record<string, unknown> {
	const out: Record<string, unknown> = {
		provider: s.provider,
		base_url: s.baseURL,
		repo_owner: s.repoOwner,
		repo_name: s.repoName,
		branch: s.branch || 'main',
		folder_path: s.folderPath,
		access_token: s.accessToken,
		mode: s.mode,
		render_markdown: s.renderMarkdown,
		// New key appended at the END so existing byte-shape assertions for
		// the other keys are minimally affected. Storage_* keys (added below
		// only when present in the existing config) trail this on edit.
		report_commit_status: s.reportCommitStatus
	};
	// deploy_strategy only when the operator deviated from the static default
	// (recreate). Backend force-downgrades blue-green for storage-backed deno
	// sites; we still persist the operator's choice and surface a UI caveat.
	if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
	// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
	// as form controls) so a form round-trip doesn't silently drop them.
	const existing = tryParse(existingJson);
	if (existing) {
		if (typeof existing.storage_enabled === 'boolean') out.storage_enabled = existing.storage_enabled;
		if (typeof existing.storage_limit_mb === 'number') out.storage_limit_mb = existing.storage_limit_mb;
	}
	return out;
}

/**
 * Keys the dockerfile form owns. Everything else in an existing config is
 * preserved on round-trip EXCEPT the static-only keys (folder_path / mode
 * / render_markdown / storage_*) which are deliberately scrubbed: after a
 * static -> dockerfile switch they'd otherwise linger as dead keys and
 * make the backend log "unknown field" noise on every save.
 */
const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
	'provider',
	'base_url',
	'repo_owner',
	'repo_name',
	'branch',
	'access_token',
	'context_path',
	'dockerfile_path',
	'port',
	'report_commit_status',
	'deploy_strategy',
	'folder_path',
	'mode',
	'render_markdown',
	'storage_enabled',
	'storage_limit_mb'
]);

export function dockerfileToConfig(
	s: DockerfileFormState,
	existingJson: string
): Record<string, unknown> {
	const preserved: Record<string, unknown> = {};
	const existing = tryParse(existingJson);
	if (existing) {
		for (const [k, v] of Object.entries(existing)) {
			if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
		}
	}
	const out: Record<string, unknown> = {
		provider: s.provider,
		base_url: s.baseURL,
		repo_owner: s.repoOwner,
		repo_name: s.repoName,
		branch: s.branch || 'main',
		access_token: s.accessToken,
		context_path: s.contextPath,
		dockerfile_path: s.dockerfilePath || 'Dockerfile',
		port: s.port || 0,
		// New owned key appended at the END of the owned block (before any
		// preserved unknown keys) so existing byte-shape assertions hold.
		report_commit_status: s.reportCommitStatus
	};
	// Owned (see DOCKERFILE_OWNED_KEYS) so it's never double-written as a
	// preserved unknown; emitted only when the operator picked a non-default.
	if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
	Object.assign(out, preserved);
	return out;
}

/** Pretty-print a config object for the Advanced-JSON editor view. */
export function stringifyConfig(config: Record<string, unknown>): string {
	return JSON.stringify(config, null, 2);
}

// ── Per-kind validity ───────────────────────────────────────────────
// Encodes the required fields per source kind. These back the wizard's
// step gating (replacing the prior opaque ~250-char boolean). Optional
// fields (folder_path, context_path, healthcheck, resource limits, ...)
// are intentionally not required here.

export function isImageValid(s: ImageFormState): boolean {
	return s.ref.trim() !== '';
}

export function isComposeValid(s: ComposeFormState): boolean {
	return s.yaml.trim() !== '';
}

function isGitSourceValid(s: GitSourceState): boolean {
	return s.baseURL.trim() !== '' && s.repoOwner.trim() !== '' && s.repoName.trim() !== '';
}

export function isStaticValid(s: StaticFormState): boolean {
	return isGitSourceValid(s);
}

export function isDockerfileValid(s: DockerfileFormState): boolean {
	return isGitSourceValid(s) && typeof s.port === 'number' && Number.isFinite(s.port) && s.port > 0;
}
