export interface PaginatedResponse<T> {
	data: T[];
	nextCursor?: string;
}

export interface ClientOptions {
	baseUrl: string;
	apiKey: string;
	debug?: (message: string) => void;
}

export interface ImportPackageFields {
	projectId?: string;
	folderId?: string;
	credentialMatchingMode?: string;
	credentialMissingMode?: string;
	bindings?: string;
	workflowConflictPolicy: string;
	workflowPublishingPolicy?: string;
	workflowIdPolicy?: string;
	missingNodeTypeMode?: string;
	projectConflictPolicy?: string;
	folderConflictPolicy?: string;
	overwriteDeletionPolicy?: string;
	dataTableMatchingMode?: string;
	dataTableMissingMode?: string;
	dataTableSchemaConflictPolicy?: string;
	variableMissingMode?: string;
	variableConflictPolicy?: string;
	variableParentPolicy?: string;
	tagMissingMode?: string;
	tagConflictPolicy?: string;
}

export interface ExportPackageFields {
	workflowIds?: string[];
	folderIds?: string[];
	projectIds?: string[];
	includeVariableValues?: boolean;
	includeTags?: boolean;
	missingWorkflowDependencyPolicy?: string;
	workflowVersionPolicy?: string;
	credentialExportPolicy?: string;
}

/** True per-entity counts of what ended up in an exported package. */
export interface ExportPackageCounts {
	workflows: number;
	folders: number;
	credentials: number;
	dataTables: number;
	variables: number;
	/** Absent when the server predates tag export. */
	tags?: number;
}

export interface ExportPackageResult {
	archive: Buffer;
	/** Undefined when talking to an older server that doesn't send the counts header. */
	counts?: ExportPackageCounts;
}

/** Outcome of pushing a Git connection's projects to its working copy. */
export type PushGitConnectionResult = {
	connectionId: string;
	counts: ExportPackageCounts;
	commitSha: string;
};

export interface ImportPackageCounts {
	projects: { created: number; updated: number; skipped: number; deleted: number };
	folders: { created: number; skipped: number; removed: number };
	workflows: {
		created: number;
		updated: number;
		skipped: number;
		archived: number;
		deleted: number;
		publishing: {
			published: number;
			unpublished: number;
			unchanged: number;
			blocked: number;
			failed: number;
		};
	};
	credentials: { matched: number; stubbed: number };
	dataTables: { matched: number; created: number };
	variables: {
		matched: number;
		created: number;
		updated: number;
		stubbed: number;
		missing: number;
	};
	tags: { matched: number; created: number; renamed: number; reconciled: number; skipped: number };
}

export type PullGitConnectionResult = {
	connectionId: string;
	counts: ImportPackageCounts;
	commitSha: string;
};

export class ApiError extends Error {
	constructor(
		readonly statusCode: number,
		message: string,
		readonly hint?: string,
		readonly details?: unknown,
	) {
		super(message);
		this.name = 'ApiError';
	}
}

export class N8nClient {
	private readonly baseUrl: string;

	private readonly headers: Headers;

	private readonly debug?: (message: string) => void;

	constructor(options: ClientOptions) {
		let url = options.baseUrl.replace(/\/+$/, '');
		if (!url.endsWith('/api/v1')) {
			url = `${url}/api/v1`;
		}
		this.baseUrl = url;
		this.headers = new Headers({
			'X-N8N-API-KEY': options.apiKey,
			'Content-Type': 'application/json',
			Accept: 'application/json',
			'User-Agent': 'n8n-cli',
		});
		this.debug = options.debug;
	}

	// ─── Low-level HTTP ────────────────────────────────────────────

	private async request<T>(
		method: string,
		path: string,
		options: {
			body?: unknown;
			query?: Record<string, string>;
			formData?: FormData;
			responseType?: 'json' | 'binary';
			onResponse?: (response: Response) => void;
		} = {},
	): Promise<T> {
		const url = new URL(`${this.baseUrl}${path}`);
		if (options.query) {
			for (const [k, v] of Object.entries(options.query)) {
				if (v !== undefined && v !== '') url.searchParams.set(k, v);
			}
		}

		this.debug?.(`→ ${method} ${url}`);
		const start = Date.now();

		const headers = new Headers(this.headers);
		let body: BodyInit | undefined;
		if (options.formData) {
			headers.delete('Content-Type');
			body = options.formData;
		} else if (options.body !== undefined) {
			body = JSON.stringify(options.body);
		}

		let response: Response;
		try {
			response = await fetch(url.toString(), { method, headers, body });
		} catch (error) {
			const msg = error instanceof Error ? error.message : String(error);
			this.debug?.(`✗ Connection failed (${Date.now() - start}ms): ${msg}`);
			throw new ApiError(
				0,
				`Could not connect to n8n at ${this.baseUrl.replace('/api/v1', '')}`,
				`Connection error: ${msg}. Check the URL and ensure the instance is running.`,
			);
		}

		this.debug?.(`← ${response.status} ${response.statusText} (${Date.now() - start}ms)`);

		if (!response.ok) {
			const errorBody = await this.readBody(response);
			const message =
				typeof errorBody === 'object' && errorBody !== null && 'message' in errorBody
					? String((errorBody as Record<string, unknown>).message)
					: `Request failed (${response.status})`;
			const hint =
				response.status === 401
					? "Check your API key. Run 'n8n-cli config set-api-key <key>' or set N8N_API_KEY."
					: response.status === 404
						? 'Resource not found. Verify the ID is correct.'
						: undefined;
			throw new ApiError(response.status, message, hint, errorBody);
		}

		options.onResponse?.(response);

		if (response.status === 204) {
			return undefined as T;
		}

		if (options.responseType === 'binary') {
			return Buffer.from(await response.arrayBuffer()) as T;
		}

		return (await this.readBody(response)) as T;
	}

	private async readBody(response: Response): Promise<unknown> {
		const contentType = response.headers.get('content-type') ?? '';
		return contentType.includes('application/json') ? await response.json() : await response.text();
	}

	private async get<T>(path: string, query?: Record<string, string>): Promise<T> {
		return await this.request('GET', path, { query });
	}

	private async post<T>(path: string, body?: unknown): Promise<T> {
		return await this.request('POST', path, { body });
	}

	private async put<T>(path: string, body?: unknown): Promise<T> {
		return await this.request('PUT', path, { body });
	}

	private async patch<T>(path: string, body?: unknown): Promise<T> {
		return await this.request('PATCH', path, { body });
	}

	private async del<T>(path: string, query?: Record<string, string>): Promise<T> {
		return await this.request('DELETE', path, { query });
	}

	/** Fetch all pages of a paginated list endpoint. */
	async paginate<T>(
		path: string,
		query: Record<string, string> = {},
		limit?: number,
	): Promise<T[]> {
		const results: T[] = [];
		let cursor: string | undefined;

		do {
			const q: Record<string, string> = { ...query, ...(cursor ? { cursor } : {}) };
			if (limit !== undefined) {
				q.limit = String(Math.min(limit - results.length, 250));
			}
			const page = await this.get<PaginatedResponse<T>>(path, q);
			results.push(...page.data);
			cursor = page.nextCursor;
		} while (cursor && (limit === undefined || results.length < limit));

		return limit !== undefined ? results.slice(0, limit) : results;
	}

	// ─── Git connections ───────────────────────────────────────────

	async listGitConnections(limit?: number) {
		return await this.paginate<Record<string, unknown>>('/git-connections', {}, limit);
	}

	async getGitConnection(id: string) {
		return await this.get<Record<string, unknown>>(`/git-connections/${id}`);
	}

	async createGitConnection(body: unknown) {
		return await this.post<Record<string, unknown>>('/git-connections', body);
	}

	async updateGitConnection(id: string, body: unknown) {
		return await this.put<Record<string, unknown>>(`/git-connections/${id}`, body);
	}

	async cloneGitConnection(id: string, branchName?: string) {
		return await this.post<Record<string, unknown>>(`/git-connections/${id}/clone`, {
			...(branchName ? { branchName } : {}),
		});
	}

	async disconnectGitConnection(id: string) {
		return await this.post<Record<string, unknown>>(`/git-connections/${id}/disconnect`);
	}

	async deleteGitConnection(id: string) {
		return await this.del<undefined>(`/git-connections/${id}`);
	}

	async pushGitConnectionProjects(id: string, body: { commitMessage: string; force?: boolean }) {
		return await this.post<PushGitConnectionResult>(`/git-connections/${id}/push`, body);
	}

	async listGitConnectionProjects(id: string) {
		return await this.get<{ projectIds: string[] }>(`/git-connections/${id}/projects`);
	}

	async addProjectToGitConnection(id: string, projectId: string) {
		return await this.post<Record<string, unknown>>(`/git-connections/${id}/projects/${projectId}`);
	}

	async removeProjectFromGitConnection(id: string, projectId: string) {
		return await this.del<undefined>(`/git-connections/${id}/projects/${projectId}`);
	}

	async pullGitConnectionProjects(id: string) {
		return await this.post<PullGitConnectionResult>(`/git-connections/${id}/pull`);
	}

	// ─── Workflows ─────────────────────────────────────────────────

	async listWorkflows(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/workflows', query, limit);
	}

	async getWorkflow(id: string) {
		return await this.get<Record<string, unknown>>(`/workflows/${id}`);
	}

	async createWorkflow(body: unknown) {
		return await this.post<Record<string, unknown>>('/workflows', body);
	}

	async updateWorkflow(id: string, body: unknown) {
		return await this.put<Record<string, unknown>>(`/workflows/${id}`, body);
	}

	async deleteWorkflow(id: string) {
		return await this.del<Record<string, unknown>>(`/workflows/${id}`);
	}

	async activateWorkflow(id: string) {
		return await this.post<Record<string, unknown>>(`/workflows/${id}/activate`);
	}

	async deactivateWorkflow(id: string) {
		return await this.post<Record<string, unknown>>(`/workflows/${id}/deactivate`);
	}

	async getWorkflowTags(id: string) {
		return await this.get<Array<Record<string, unknown>>>(`/workflows/${id}/tags`);
	}

	async updateWorkflowTags(id: string, tagIds: string[]) {
		return await this.put<Array<Record<string, unknown>>>(
			`/workflows/${id}/tags`,
			tagIds.map((tid) => ({ id: tid })),
		);
	}

	async transferWorkflow(id: string, projectId: string) {
		return await this.put<undefined>(`/workflows/${id}/transfer`, {
			destinationProjectId: projectId,
		});
	}

	// ─── Executions ────────────────────────────────────────────────

	async listExecutions(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/executions', query, limit);
	}

	async getExecution(id: string, includeData = false) {
		const query: Record<string, string> = includeData ? { includeData: 'true' } : {};
		return await this.get<Record<string, unknown>>(`/executions/${id}`, query);
	}

	async retryExecution(id: string) {
		return await this.post<Record<string, unknown>>(`/executions/${id}/retry`);
	}

	async stopExecution(id: string) {
		return await this.post<Record<string, unknown>>(`/executions/${id}/stop`);
	}

	async deleteExecution(id: string) {
		return await this.del<Record<string, unknown>>(`/executions/${id}`);
	}

	// ─── Credentials ───────────────────────────────────────────────

	async listCredentials(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/credentials', query, limit);
	}

	async getCredential(id: string) {
		return await this.get<Record<string, unknown>>(`/credentials/${id}`);
	}

	async getCredentialSchema(typeName: string) {
		return await this.get<Record<string, unknown>>(`/credentials/schema/${typeName}`);
	}

	async createCredential(body: unknown) {
		return await this.post<Record<string, unknown>>('/credentials', body);
	}

	async deleteCredential(id: string) {
		return await this.del<Record<string, unknown>>(`/credentials/${id}`);
	}

	async transferCredential(id: string, projectId: string) {
		return await this.put<undefined>(`/credentials/${id}/transfer`, {
			destinationProjectId: projectId,
		});
	}

	// ─── Tags ──────────────────────────────────────────────────────

	async listTags(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/tags', query, limit);
	}

	async createTag(name: string) {
		return await this.post<Record<string, unknown>>('/tags', { name });
	}

	async updateTag(id: string, name: string) {
		return await this.put<Record<string, unknown>>(`/tags/${id}`, { name });
	}

	async deleteTag(id: string) {
		return await this.del<Record<string, unknown>>(`/tags/${id}`);
	}

	// ─── Projects ──────────────────────────────────────────────────

	async listProjects(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/projects', query, limit);
	}

	async getProject(id: string) {
		return await this.get<Record<string, unknown>>(`/projects/${id}`);
	}

	async createProject(name: string) {
		return await this.post<Record<string, unknown>>('/projects', { name });
	}

	async updateProject(id: string, name: string) {
		return await this.put<undefined>(`/projects/${id}`, { name });
	}

	async deleteProject(id: string) {
		return await this.del<undefined>(`/projects/${id}`);
	}

	async listProjectMembers(projectId: string, limit?: number) {
		return await this.paginate<Record<string, unknown>>(`/projects/${projectId}/users`, {}, limit);
	}

	async addProjectMember(projectId: string, userId: string, role: string) {
		return await this.post<undefined>(`/projects/${projectId}/users`, {
			relations: [{ userId, role }],
		});
	}

	async updateProjectMemberRole(projectId: string, userId: string, role: string) {
		return await this.patch<undefined>(`/projects/${projectId}/users/${userId}`, { role });
	}

	async removeProjectMember(projectId: string, userId: string) {
		return await this.del<undefined>(`/projects/${projectId}/users/${userId}`);
	}

	// ─── Variables ─────────────────────────────────────────────────

	async listVariables(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/variables', query, limit);
	}

	async createVariable(key: string, value: string) {
		return await this.post<Record<string, unknown>>('/variables', { key, value });
	}

	async updateVariable(id: string, key: string, value: string) {
		return await this.put<undefined>(`/variables/${id}`, { key, value });
	}

	async deleteVariable(id: string) {
		return await this.del<undefined>(`/variables/${id}`);
	}

	// ─── Data Tables ───────────────────────────────────────────────

	async listDataTables(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/data-tables', query, limit);
	}

	async getDataTable(id: string) {
		return await this.get<Record<string, unknown>>(`/data-tables/${id}`);
	}

	async createDataTable(body: unknown) {
		return await this.post<Record<string, unknown>>('/data-tables', body);
	}

	async deleteDataTable(id: string) {
		return await this.del<undefined>(`/data-tables/${id}`);
	}

	async listDataTableRows(tableId: string, query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>(
			`/data-tables/${tableId}/rows`,
			query,
			limit,
		);
	}

	async addDataTableRows(tableId: string, data: unknown[]) {
		return await this.post<Record<string, unknown>>(`/data-tables/${tableId}/rows`, {
			data,
			returnType: 'all',
		});
	}

	async updateDataTableRows(tableId: string, filter: unknown, data: unknown) {
		return await this.patch<unknown>(`/data-tables/${tableId}/rows/update`, {
			filter,
			data,
			returnData: true,
		});
	}

	async upsertDataTableRows(tableId: string, filter: unknown, data: unknown) {
		return await this.post<unknown>(`/data-tables/${tableId}/rows/upsert`, {
			filter,
			data,
			returnData: true,
		});
	}

	async deleteDataTableRows(tableId: string, filter: string) {
		return await this.del<unknown>(`/data-tables/${tableId}/rows/delete`, {
			filter,
			returnData: 'true',
		});
	}

	// ─── Users ─────────────────────────────────────────────────────

	async listUsers(query: Record<string, string> = {}, limit?: number) {
		return await this.paginate<Record<string, unknown>>('/users', query, limit);
	}

	async getUser(id: string) {
		return await this.get<Record<string, unknown>>(`/users/${id}`);
	}

	// ─── Source Control ────────────────────────────────────────────

	async sourceControlPull(options: { force?: boolean } = {}) {
		return await this.post<Record<string, unknown>>('/source-control/pull', {
			force: options.force ?? false,
		});
	}

	// ─── Packages (beta) ───────────────────────────────────────────

	async exportPackage(fields: ExportPackageFields): Promise<ExportPackageResult> {
		// Empty collections are dropped so the API's per-field "at least one" rule isn't tripped.
		const body: {
			workflowIds?: string[];
			folderIds?: string[];
			projectIds?: string[];
			includeVariableValues?: boolean;
			includeTags?: boolean;
			missingWorkflowDependencyPolicy?: string;
			workflowVersionPolicy?: string;
			credentialExportPolicy?: string;
		} = {};
		if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
		if (fields.folderIds?.length) body.folderIds = fields.folderIds;
		if (fields.projectIds?.length) body.projectIds = fields.projectIds;
		// `undefined` is dropped by JSON serialization, so the API's default applies.
		body.includeVariableValues = fields.includeVariableValues;
		body.includeTags = fields.includeTags;
		if (fields.missingWorkflowDependencyPolicy)
			body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
		if (fields.workflowVersionPolicy) body.workflowVersionPolicy = fields.workflowVersionPolicy;
		// Only sent when set, so an older server without this field in its schema never sees it.
		if (fields.credentialExportPolicy) body.credentialExportPolicy = fields.credentialExportPolicy;

		let counts: ExportPackageCounts | undefined;
		const archive = await this.request<Buffer>('POST', '/n8n-packages/export', {
			body,
			responseType: 'binary',
			// Older servers omit this header; counts then stays undefined.
			onResponse: (response) => {
				const header = response.headers.get('X-N8n-Export-Counts');
				if (header) counts = this.parseExportCounts(header);
			},
		});

		return { archive, counts };
	}

	private parseExportCounts(header: string): ExportPackageCounts | undefined {
		try {
			return JSON.parse(header) as ExportPackageCounts;
		} catch {
			return undefined;
		}
	}

	async importPackage(
		file: { buffer: Buffer; filename: string },
		fields: ImportPackageFields,
	): Promise<Record<string, unknown>> {
		const form = new FormData();
		form.append('package', new Blob([new Uint8Array(file.buffer)]), file.filename);
		for (const [key, value] of Object.entries(fields)) {
			if (typeof value === 'string' && value !== '') form.append(key, value);
		}
		return await this.request<Record<string, unknown>>('POST', '/n8n-packages/import', {
			formData: form,
		});
	}

	// ─── Audit ─────────────────────────────────────────────────────

	async audit(categories?: string[]) {
		const body: Record<string, unknown> = {};
		if (categories) {
			body.additionalOptions = { categories };
		}
		return await this.post<Record<string, unknown>>('/audit', body);
	}
}
