import type { FileReadResult } from 'node:fs/promises';
import { O_APPEND, O_CREAT, O_EXCL, O_RDONLY, O_RDWR, O_SYNC, O_TRUNC, O_WRONLY, S_IFMT, size_max } from './emulation/constants.js';
import { config } from './emulation/config.js';
import { Errno, ErrnoError } from './error.js';
import type { FileSystem } from './filesystem.js';
import './polyfills.js';
import { Stats, type FileType } from './stats.js';

/**
	Typescript does not include a type declaration for resizable array buffers. 
	It has been standardized into ECMAScript though
	@todo Remove this if TS adds them to lib declarations
*/
declare global {
	interface ArrayBuffer {
		readonly resizable: boolean;

		readonly maxByteLength?: number;

		resize(newLength: number): void;
	}

	interface SharedArrayBuffer {
		readonly resizable: boolean;

		readonly maxByteLength?: number;

		resize(newLength: number): void;
	}

	interface ArrayBufferConstructor {
		new (byteLength: number, options: { maxByteLength?: number }): ArrayBuffer;
	}
}

const validFlags = ['r', 'r+', 'rs', 'rs+', 'w', 'wx', 'w+', 'wx+', 'a', 'ax', 'a+', 'ax+'];

export function parseFlag(flag: string | number): string {
	if (typeof flag === 'number') {
		return flagToString(flag);
	}
	if (!validFlags.includes(flag)) {
		throw new Error('Invalid flag string: ' + flag);
	}
	return flag;
}

export function flagToString(flag: number): string {
	switch (flag) {
		case O_RDONLY:
			return 'r';
		case O_RDONLY | O_SYNC:
			return 'rs';
		case O_RDWR:
			return 'r+';
		case O_RDWR | O_SYNC:
			return 'rs+';
		case O_TRUNC | O_CREAT | O_WRONLY:
			return 'w';
		case O_TRUNC | O_CREAT | O_WRONLY | O_EXCL:
			return 'wx';
		case O_TRUNC | O_CREAT | O_RDWR:
			return 'w+';
		case O_TRUNC | O_CREAT | O_RDWR | O_EXCL:
			return 'wx+';
		case O_APPEND | O_CREAT | O_WRONLY:
			return 'a';
		case O_APPEND | O_CREAT | O_WRONLY | O_EXCL:
			return 'ax';
		case O_APPEND | O_CREAT | O_RDWR:
			return 'a+';
		case O_APPEND | O_CREAT | O_RDWR | O_EXCL:
			return 'ax+';
		default:
			throw new Error('Invalid flag number: ' + flag);
	}
}

export function flagToNumber(flag: string): number {
	switch (flag) {
		case 'r':
			return O_RDONLY;
		case 'rs':
			return O_RDONLY | O_SYNC;
		case 'r+':
			return O_RDWR;
		case 'rs+':
			return O_RDWR | O_SYNC;
		case 'w':
			return O_TRUNC | O_CREAT | O_WRONLY;
		case 'wx':
			return O_TRUNC | O_CREAT | O_WRONLY | O_EXCL;
		case 'w+':
			return O_TRUNC | O_CREAT | O_RDWR;
		case 'wx+':
			return O_TRUNC | O_CREAT | O_RDWR | O_EXCL;
		case 'a':
			return O_APPEND | O_CREAT | O_WRONLY;
		case 'ax':
			return O_APPEND | O_CREAT | O_WRONLY | O_EXCL;
		case 'a+':
			return O_APPEND | O_CREAT | O_RDWR;
		case 'ax+':
			return O_APPEND | O_CREAT | O_RDWR | O_EXCL;
		default:
			throw new Error('Invalid flag string: ' + flag);
	}
}

/**
 * Parses a flag as a mode (W_OK, R_OK, and/or X_OK)
 * @param flag the flag to parse
 */
export function flagToMode(flag: string): number {
	let mode = 0;
	mode <<= 1;
	mode += +isReadable(flag);
	mode <<= 1;
	mode += +isWriteable(flag);
	mode <<= 1;
	return mode;
}

export function isReadable(flag: string): boolean {
	return flag.indexOf('r') !== -1 || flag.indexOf('+') !== -1;
}

export function isWriteable(flag: string): boolean {
	return flag.indexOf('w') !== -1 || flag.indexOf('a') !== -1 || flag.indexOf('+') !== -1;
}

export function isTruncating(flag: string): boolean {
	return flag.indexOf('w') !== -1;
}

export function isAppendable(flag: string): boolean {
	return flag.indexOf('a') !== -1;
}

export function isSynchronous(flag: string): boolean {
	return flag.indexOf('s') !== -1;
}

export function isExclusive(flag: string): boolean {
	return flag.indexOf('x') !== -1;
}

export abstract class File {
	public constructor(
		/**
		 * @internal
		 * The file system that created the file
		 */
		public fs: FileSystem,
		public readonly path: string
	) {}

	/**
	 * Get the current file position.
	 */
	public abstract position: number;

	public abstract stat(): Promise<Stats>;
	public abstract statSync(): Stats;

	public abstract close(): Promise<void>;
	public abstract closeSync(): void;

	public [Symbol.asyncDispose](): Promise<void> {
		return this.close();
	}

	public [Symbol.dispose](): void {
		return this.closeSync();
	}

	public abstract truncate(len: number): Promise<void>;
	public abstract truncateSync(len: number): void;

	public abstract sync(): Promise<void>;
	public abstract syncSync(): void;

	/**
	 * Write buffer to the file.
	 * @param buffer Uint8Array containing the data to write to the file.
	 * @param offset Offset in the buffer to start reading data from.
	 * @param length The amount of bytes to write to the file.
	 * @param position Offset from the beginning of the file where this data should be written.
	 * If position is null, the data will be written at the current position.
	 * @returns Promise resolving to the new length of the buffer
	 */
	public abstract write(buffer: Uint8Array, offset?: number, length?: number, position?: number): Promise<number>;

	/**
	 * Write buffer to the file.
	 * @param buffer Uint8Array containing the data to write to the file.
	 * @param offset Offset in the buffer to start reading data from.
	 * @param length The amount of bytes to write to the file.
	 * @param position Offset from the beginning of the file where this data should be written.
	 * If position is null, the data will be written at the current position.
	 */
	public abstract writeSync(buffer: Uint8Array, offset?: number, length?: number, position?: number): number;

	/**
	 * Read data from the file.
	 * @param buffer The buffer that the data will be written to.
	 * @param offset The offset within the buffer where writing will start.
	 * @param length An integer specifying the number of bytes to read.
	 * @param position An integer specifying where to begin reading from in the file.
	 * If position is null, data will be read from the current file position.
	 * @returns Promise resolving to the new length of the buffer
	 */
	public abstract read<TBuffer extends NodeJS.ArrayBufferView>(buffer: TBuffer, offset?: number, length?: number, position?: number): Promise<FileReadResult<TBuffer>>;

	/**
	 * Read data from the file.
	 * @param buffer The buffer that the data will be written to.
	 * @param offset The offset within the buffer where writing will start.
	 * @param length An integer specifying the number of bytes to read.
	 * @param position An integer specifying where to begin reading from in the file.
	 * If position is null, data will be read from the current file position.
	 */
	public abstract readSync(buffer: ArrayBufferView, offset?: number, length?: number, position?: number): number;

	/**
	 * Default implementation maps to `sync`.
	 */
	public datasync(): Promise<void> {
		return this.sync();
	}

	/**
	 * Default implementation maps to `syncSync`.
	 */
	public datasyncSync(): void {
		return this.syncSync();
	}

	public abstract chown(uid: number, gid: number): Promise<void>;
	public abstract chownSync(uid: number, gid: number): void;

	public abstract chmod(mode: number): Promise<void>;
	public abstract chmodSync(mode: number): void;

	/**
	 * Change the file timestamps of the file.
	 */
	public abstract utimes(atime: Date, mtime: Date): Promise<void>;

	/**
	 * Change the file timestamps of the file.
	 */
	public abstract utimesSync(atime: Date, mtime: Date): void;

	/**
	 * Set the file type
	 * @internal
	 */
	public abstract _setType(type: FileType): Promise<void>;

	/**
	 * Set the file type
	 * @internal
	 */
	public abstract _setTypeSync(type: FileType): void;
}

/**
 * An implementation of `File` that operates completely in-memory.
 * `PreloadFile`s are backed by a `Uint8Array`.
 */
export class PreloadFile<FS extends FileSystem> extends File {
	/**
	 * Current position
	 */
	protected _position: number = 0;

	/**
	 * Whether the file has changes which have not been written to the FS
	 */
	protected dirty: boolean = false;

	/**
	 * Whether the file is open or closed
	 */
	protected closed: boolean = false;

	/**
	 * Creates a file with `path` and, optionally, the given contents.
	 * Note that, if contents is specified, it will be mutated by the file.
	 */
	public constructor(
		/**
		 * The file system that created the file.
		 * @internal
		 */
		public fs: FS,
		path: string,
		public readonly flag: string,
		public readonly stats: Stats,
		/**
		 * A buffer containing the entire contents of the file.
		 */
		protected _buffer: Uint8Array = new Uint8Array(new ArrayBuffer(0, fs.metadata().noResizableBuffers ? {} : { maxByteLength: size_max }))
	) {
		super(fs, path);

		/*
			Note: 
			This invariant is *not* maintained once the file starts getting modified.
			It only actually matters if file is readable, as writeable modes may truncate/append to file.
		*/
		if (this.stats.size == _buffer.byteLength) {
			return;
		}

		if (isReadable(this.flag)) {
			throw new Error(`Size mismatch: buffer length ${_buffer.byteLength}, stats size ${this.stats.size}`);
		}

		this.dirty = true;
	}

	/**
	 * Get the underlying buffer for this file. Mutating not recommended and will mess up dirty tracking.
	 */
	public get buffer(): Uint8Array {
		return this._buffer;
	}

	/**
	 * Get the current file position.
	 *
	 * We emulate the following bug mentioned in the Node documentation:
	 *
	 * On Linux, positional writes don't work when the file is opened in append mode.
	 * The kernel ignores the position argument and always appends the data to the end of the file.
	 * @returns The current file position.
	 */
	public get position(): number {
		if (isAppendable(this.flag)) {
			return this.stats.size;
		}
		return this._position;
	}

	public set position(value: number) {
		this._position = value;
	}

	public async sync(): Promise<void> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.sync');
		}
		if (!this.dirty) {
			return;
		}
		await this.fs.sync(this.path, this._buffer, this.stats);
		this.dirty = false;
	}

	public syncSync(): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.sync');
		}
		if (!this.dirty) {
			return;
		}
		this.fs.syncSync(this.path, this._buffer, this.stats);
		this.dirty = false;
	}

	public async close(): Promise<void> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.close');
		}
		await this.sync();
		this.dispose();
	}

	public closeSync(): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.close');
		}
		this.syncSync();
		this.dispose();
	}

	/**
	 * Cleans up. This will *not* sync the file data to the FS
	 */
	protected dispose(force?: boolean): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.dispose');
		}
		if (this.dirty && !force) {
			throw ErrnoError.With('EBUSY', this.path, 'File.dispose');
		}

		// @ts-expect-error 2790
		delete this._buffer;
		// @ts-expect-error 2790
		delete this.stats;

		this.closed = true;
	}

	public stat(): Promise<Stats> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.stat');
		}
		return Promise.resolve(new Stats(this.stats));
	}

	public statSync(): Stats {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.stat');
		}
		return new Stats(this.stats);
	}

	protected _truncate(length: number): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.truncate');
		}
		this.dirty = true;
		if (!isWriteable(this.flag)) {
			throw new ErrnoError(Errno.EPERM, 'File not opened with a writeable mode.');
		}
		this.stats.mtimeMs = Date.now();
		if (length > this._buffer.length) {
			const data = new Uint8Array(length - this._buffer.length);
			// Write will set stats.size and handle syncing.
			this.writeSync(data, 0, data.length, this._buffer.length);
			return;
		}
		this.stats.size = length;
		// Truncate.
		this._buffer = this._buffer.slice(0, length);
	}

	public async truncate(length: number): Promise<void> {
		this._truncate(length);
		await this.sync();
	}

	public truncateSync(length: number): void {
		this._truncate(length);
		this.syncSync();
	}

	protected _write(buffer: Uint8Array, offset: number = 0, length: number = this.stats.size, position: number = this.position): number {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.write');
		}
		this.dirty = true;
		if (!isWriteable(this.flag)) {
			throw new ErrnoError(Errno.EPERM, 'File not opened with a writeable mode.');
		}
		const end = position + length;

		if (end > this.stats.size) {
			this.stats.size = end;
			if (end > this._buffer.byteLength) {
				if (this._buffer.buffer.resizable && this._buffer.buffer.maxByteLength! <= end) {
					this._buffer.buffer.resize(end);
				} else {
					// Extend the buffer!
					const newBuffer = new Uint8Array(new ArrayBuffer(end, this.fs.metadata().noResizableBuffers ? {} : { maxByteLength: size_max }));
					newBuffer.set(this._buffer);
					this._buffer = newBuffer;
				}
			}
		}
		const slice = buffer.slice(offset, offset + length);
		this._buffer.set(slice, position);
		this.stats.mtimeMs = Date.now();
		this.position = position + slice.byteLength;
		return slice.byteLength;
	}

	/**
	 * Write buffer to the file.
	 * @param buffer Uint8Array containing the data to write to the file.
	 * @param offset Offset in the buffer to start reading data from.
	 * @param length The amount of bytes to write to the file.
	 * @param position Offset from the beginning of the file where this data should be written.
	 * If position is null, the data will be written at  the current position.
	 */
	public async write(buffer: Uint8Array, offset?: number, length?: number, position?: number): Promise<number> {
		const bytesWritten = this._write(buffer, offset, length, position);
		await this.sync();
		return bytesWritten;
	}

	/**
	 * Write buffer to the file.
	 * @param buffer Uint8Array containing the data to write to the file.
	 * @param offset Offset in the buffer to start reading data from.
	 * @param length The amount of bytes to write to the file.
	 * @param position Offset from the beginning of the file where this data should be written.
	 * If position is null, the data will be written at  the current position.
	 * @returns bytes written
	 */
	public writeSync(buffer: Uint8Array, offset: number = 0, length: number = this.stats.size, position: number = this.position): number {
		const bytesWritten = this._write(buffer, offset, length, position);
		this.syncSync();
		return bytesWritten;
	}

	protected _read(buffer: ArrayBufferView, offset: number = 0, length: number = this.stats.size, position?: number): number {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.read');
		}
		if (!isReadable(this.flag)) {
			throw new ErrnoError(Errno.EPERM, 'File not opened with a readable mode.');
		}
		this.dirty = true;
		position ??= this.position;
		let end = position + length;
		if (end > this.stats.size) {
			end = position + Math.max(this.stats.size - position, 0);
		}
		this.stats.atimeMs = Date.now();
		this._position = end;
		const bytesRead = end - position;
		if (bytesRead == 0) {
			// No copy/read. Return immediatly for better performance
			return bytesRead;
		}
		new Uint8Array(buffer.buffer, offset, length).set(this._buffer.slice(position, end));
		return bytesRead;
	}

	/**
	 * Read data from the file.
	 * @param buffer The buffer that the data will be written to.
	 * @param offset The offset within the buffer where writing will start.
	 * @param length An integer specifying the number of bytes to read.
	 * @param position An integer specifying where to begin reading from in the file.
	 * If position is null, data will be read from the current file position.
	 */
	public async read<TBuffer extends ArrayBufferView>(buffer: TBuffer, offset?: number, length?: number, position?: number): Promise<{ bytesRead: number; buffer: TBuffer }> {
		const bytesRead = this._read(buffer, offset, length, position);
		if (config.syncOnRead) await this.sync();
		return { bytesRead, buffer };
	}

	/**
	 * Read data from the file.
	 * @param buffer The buffer that the data will be written to.
	 * @param offset The offset within the buffer where writing will start.
	 * @param length An integer specifying the number of bytes to read.
	 * @param position An integer specifying where to begin reading from in the file.
	 * If position is null, data will be read from the current file position.
	 * @returns number of bytes written
	 */
	public readSync(buffer: ArrayBufferView, offset?: number, length?: number, position?: number): number {
		const bytesRead = this._read(buffer, offset, length, position);
		if (config.syncOnRead) this.syncSync();
		return bytesRead;
	}

	public async chmod(mode: number): Promise<void> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.chmod');
		}
		this.dirty = true;
		this.stats.chmod(mode);
		await this.sync();
	}

	public chmodSync(mode: number): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.chmod');
		}
		this.dirty = true;
		this.stats.chmod(mode);
		this.syncSync();
	}

	public async chown(uid: number, gid: number): Promise<void> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.chown');
		}
		this.dirty = true;
		this.stats.chown(uid, gid);
		await this.sync();
	}

	public chownSync(uid: number, gid: number): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.chown');
		}
		this.dirty = true;
		this.stats.chown(uid, gid);
		this.syncSync();
	}

	public async utimes(atime: Date, mtime: Date): Promise<void> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.utimes');
		}
		this.dirty = true;
		this.stats.atime = atime;
		this.stats.mtime = mtime;
		await this.sync();
	}

	public utimesSync(atime: Date, mtime: Date): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File.utimes');
		}
		this.dirty = true;
		this.stats.atime = atime;
		this.stats.mtime = mtime;
		this.syncSync();
	}

	public async _setType(type: FileType): Promise<void> {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File._setType');
		}
		this.dirty = true;
		this.stats.mode = (this.stats.mode & ~S_IFMT) | type;
		await this.sync();
	}

	public _setTypeSync(type: FileType): void {
		if (this.closed) {
			throw ErrnoError.With('EBADF', this.path, 'File._setType');
		}
		this.dirty = true;
		this.stats.mode = (this.stats.mode & ~S_IFMT) | type;
		this.syncSync();
	}

	public async [Symbol.asyncDispose]() {
		await this.close();
	}

	public [Symbol.dispose]() {
		this.closeSync();
	}
}

/**
 * For the file systems which do not sync to anything.
 */
export class NoSyncFile<T extends FileSystem> extends PreloadFile<T> {
	public sync(): Promise<void> {
		return Promise.resolve();
	}

	public syncSync(): void {}

	public close(): Promise<void> {
		return Promise.resolve();
	}

	public closeSync(): void {}
}
