const lineBreak = '\r\n';
const defaultContentType = 'application/octet-stream';
const textEncoder = new TextEncoder();

export interface IMultipartFormDataAppendOptions {
  filename: string;
  contentType?: string;
}

interface IMultipartFormDataPart {
  name: string;
  value: Uint8Array;
  filename?: string;
  contentType?: string;
}

const escapeContentDispositionValue = (value: string): string => {
  return value
    .replace(/\\/g, '\\\\')
    .replace(/\r/g, '%0D')
    .replace(/\n/g, '%0A')
    .replace(/"/g, '%22');
};

const getFilename = (filename: string): string => {
  const normalizedFilename = filename.replace(/\\/g, '/');
  return normalizedFilename.slice(normalizedFilename.lastIndexOf('/') + 1);
};

const createBoundary = (): string => {
  const randomBytes = new Uint8Array(12);
  globalThis.crypto.getRandomValues(randomBytes);
  const randomHex = Array.from(randomBytes, (byte) =>
    byte.toString(16).padStart(2, '0'),
  ).join('');
  return `--------------------------${randomHex}`;
};

/**
 * Minimal multipart/form-data encoder for the values accepted by SmartRequest.
 */
export class MultipartFormData {
  private readonly boundary: string;
  private readonly parts: IMultipartFormDataPart[] = [];

  constructor(boundary = createBoundary()) {
    if (
      boundary.length > 70 ||
      !/^[0-9A-Za-z'()+_,./:=?-]+$/.test(boundary)
    ) {
      throw new Error('Multipart boundary contains invalid characters.');
    }

    this.boundary = boundary;
  }

  public append(
    name: string,
    value: string | Uint8Array,
    options?: IMultipartFormDataAppendOptions,
  ): void {
    let filename: string | undefined;
    let contentType: string | undefined;

    if (typeof value !== 'string') {
      filename = getFilename(options?.filename || 'file');
      contentType = options?.contentType;
      this.validateContentType(contentType);
    }

    this.parts.push({
      name,
      value: typeof value === 'string' ? textEncoder.encode(value) : value,
      filename,
      contentType,
    });
  }

  public resolveMissingContentTypes(
    resolver: (filename: string) => string | null,
  ): void {
    for (const part of this.parts) {
      if (part.filename && !part.contentType) {
        part.contentType = resolver(part.filename) || defaultContentType;
        this.validateContentType(part.contentType);
      }
    }
  }

  public getHeaders(): Record<string, string> {
    return {
      'content-type': `multipart/form-data; boundary=${this.boundary}`,
    };
  }

  public getContentLength(): number {
    let length = textEncoder.encode(`--${this.boundary}--${lineBreak}`).byteLength;

    for (const part of this.parts) {
      length +=
        this.getPartHeader(part).byteLength +
        part.value.byteLength +
        lineBreak.length;
    }

    return length;
  }

  public toArrayBuffer(): ArrayBuffer {
    const buffer = new ArrayBuffer(this.getContentLength());
    const body = new Uint8Array(buffer);
    let offset = 0;

    for (const chunk of this.getChunks()) {
      body.set(chunk, offset);
      offset += chunk.byteLength;
    }

    return buffer;
  }

  public *getChunks(): Iterable<Uint8Array> {
    const lineBreakBuffer = textEncoder.encode(lineBreak);

    for (const part of this.parts) {
      yield this.getPartHeader(part);
      yield part.value;
      yield lineBreakBuffer;
    }

    yield textEncoder.encode(`--${this.boundary}--${lineBreak}`);
  }

  private getPartHeader(part: IMultipartFormDataPart): Uint8Array {
    const dispositionParts = [
      'form-data',
      `name="${escapeContentDispositionValue(part.name)}"`,
    ];
    const headerLines = [`--${this.boundary}`];

    if (part.filename) {
      dispositionParts.push(
        `filename="${escapeContentDispositionValue(part.filename)}"`,
      );
    }

    headerLines.push(
      `Content-Disposition: ${dispositionParts.join('; ')}`,
    );

    if (part.filename) {
      headerLines.push(
        `Content-Type: ${part.contentType || defaultContentType}`,
      );
    }

    return textEncoder.encode(
      `${headerLines.join(lineBreak)}${lineBreak}${lineBreak}`,
    );
  }

  private validateContentType(contentType: string | undefined): void {
    if (contentType && /[\r\n]/.test(contentType)) {
      throw new Error('Multipart content type must be a single-line value.');
    }
  }
}
