import { Buffer } from "node:buffer";
import { Readable } from "node:stream";
import type { ReadableStream as NodeWebStream } from "node:stream/web";
import {
  $env,
  $hook,
  $inject,
  Alepha,
  AlephaError,
  type FileLike,
  type Static,
  z,
} from "alepha";
import { CryptoProvider } from "alepha/crypto";
import { $logger } from "alepha/logger";
import { currentTenantAtom } from "alepha/security";
import { FileDetector, FileSystemProvider } from "alepha/system";
import { S3mini } from "s3mini";
import { FileNotFoundError } from "../errors/FileNotFoundError.ts";
import { $bucket } from "../primitives/$bucket.ts";
import type { FileStorageProvider } from "./FileStorageProvider.ts";

const envSchema = z.object({
  /**
   * S3 endpoint URL. The bucket name is appended (path-style) per request.
   *
   * Examples:
   * - AWS S3: `https://s3.us-east-1.amazonaws.com`
   * - Cloudflare R2: `https://<account-id>.r2.cloudflarestorage.com`
   * - MinIO: `http://localhost:9000`
   * - DigitalOcean Spaces: `https://<region>.digitaloceanspaces.com`
   */
  S3_ENDPOINT: z.string(),

  /**
   * AWS region or "auto" for R2.
   *
   * @default "auto"
   */
  S3_REGION: z.string().optional(),

  /**
   * Access key ID for S3 authentication.
   */
  S3_ACCESS_KEY_ID: z.string(),

  /**
   * Secret access key for S3 authentication.
   */
  S3_SECRET_ACCESS_KEY: z.string(),
});

declare module "alepha" {
  interface Env extends Partial<Static<typeof envSchema>> {}
}

/**
 * S3-compatible file storage provider for Node.js.
 *
 * Backed by `s3mini` (zero-dep, ~20 KB). Works with AWS S3, Cloudflare R2,
 * MinIO, DigitalOcean Spaces, Backblaze B2, and any other S3-compatible service.
 *
 * Uses path-style addressing (`<endpoint>/<bucket>`).
 */
export class S3FileStorageProvider implements FileStorageProvider {
  protected readonly log = $logger();
  protected readonly env = $env(envSchema);
  protected readonly alepha = $inject(Alepha);
  protected readonly fileSystem = $inject(FileSystemProvider);
  protected readonly fileDetector = $inject(FileDetector);
  protected readonly crypto = $inject(CryptoProvider);
  protected readonly clients: Map<string, S3mini> = new Map();

  /**
   * Convert bucket name to S3-compatible format.
   * S3 bucket names must be lowercase, 3-63 characters, no underscores.
   */
  public convertName(name: string): string {
    return name.replaceAll("/", "-").replaceAll("_", "-").toLowerCase();
  }

  protected getClient(bucketName: string): S3mini {
    const name = this.convertName(bucketName);
    let client = this.clients.get(name);
    if (!client) {
      const endpoint = this.env.S3_ENDPOINT.replace(/\/+$/, "");
      client = new S3mini({
        accessKeyId: this.env.S3_ACCESS_KEY_ID,
        secretAccessKey: this.env.S3_SECRET_ACCESS_KEY,
        region: this.env.S3_REGION || "auto",
        endpoint: `${endpoint}/${name}`,
      });
      this.clients.set(name, client);
    }
    return client;
  }

  protected readonly onStart = $hook({
    on: "start",
    handler: async () => {
      for (const bucket of this.alepha.primitives($bucket)) {
        if (bucket.provider !== this) {
          continue;
        }

        const name = this.convertName(bucket.name);
        const client = this.getClient(bucket.name);

        this.log.debug(`Preparing S3 bucket '${name}'...`);

        const exists = await client.bucketExists();
        if (!exists) {
          this.log.debug(`Creating S3 bucket '${name}'...`);
          const created = await client.createBucket();
          if (!created) {
            throw new AlephaError(`Failed to create S3 bucket '${name}'`);
          }
        }

        this.log.info(`S3 bucket '${bucket.name}' OK`);
      }
    },
  });

  /**
   * Object key, tenant-scoped when a tenant is active (`currentTenantAtom`):
   * `{tenantId}/{fileId}` within the per-bucket S3 bucket. No tenant → `{fileId}`.
   */
  protected key(fileId: string): string {
    const tenantId = this.alepha.store.get(currentTenantAtom)?.id;
    return tenantId ? `${tenantId}/${fileId}` : fileId;
  }

  protected createId(mimeType: string): string {
    const ext = this.fileDetector.getExtensionFromMimeType(mimeType);
    return `${this.crypto.randomUUID()}.${ext}`;
  }

  public async upload(
    bucketName: string,
    file: FileLike,
    fileId?: string,
  ): Promise<string> {
    fileId ??= this.createId(file.type);

    this.log.trace(
      `Uploading file '${file.name}' to bucket '${bucketName}' with id '${fileId}'...`,
    );

    const client = this.getClient(bucketName);

    try {
      const buffer = new Uint8Array(await file.arrayBuffer());
      await client.putObject(
        this.key(fileId),
        buffer,
        file.type || "application/octet-stream",
        undefined,
        { "x-amz-meta-name": encodeURIComponent(file.name) },
        file.size,
      );

      this.log.trace(`File uploaded successfully: ${fileId}`);
      return fileId;
    } catch (error) {
      this.log.error(`Failed to upload file: ${error}`);
      if (error instanceof Error) {
        throw new AlephaError(`Upload failed: ${error.message}`, {
          cause: error,
        });
      }
      throw error;
    }
  }

  public async download(bucketName: string, fileId: string): Promise<FileLike> {
    this.log.trace(
      `Downloading file '${fileId}' from bucket '${bucketName}'...`,
    );

    const client = this.getClient(bucketName);
    const response = await client.getObjectResponse(this.key(fileId));

    if (!response) {
      throw new FileNotFoundError(
        `File '${fileId}' not found in bucket '${bucketName}'`,
      );
    }

    const mimeType =
      response.headers.get("content-type") ||
      this.fileDetector.getContentType(fileId);

    const metaName = response.headers.get("x-amz-meta-name");
    const name = metaName ? decodeURIComponent(metaName) : fileId;

    const contentLength = response.headers.get("content-length");
    const size = contentLength ? Number.parseInt(contentLength, 10) : 0;

    // Stream the body straight through instead of buffering the whole object
    // into memory. `response.body` is null only for a zero-byte object.
    if (!response.body) {
      return this.fileSystem.createFile({
        buffer: Buffer.alloc(0),
        name,
        type: mimeType,
      });
    }

    return this.fileSystem.createFile({
      stream: Readable.fromWeb(response.body as unknown as NodeWebStream),
      name,
      type: mimeType,
      size,
    });
  }

  public async exists(bucketName: string, fileId: string): Promise<boolean> {
    this.log.trace(
      `Checking existence of file '${fileId}' in bucket '${bucketName}'...`,
    );

    const client = this.getClient(bucketName);
    const result = await client.objectExists(this.key(fileId));
    return result === true;
  }

  public async delete(bucketName: string, fileId: string): Promise<void> {
    this.log.trace(`Deleting file '${fileId}' from bucket '${bucketName}'...`);

    const client = this.getClient(bucketName);

    try {
      await client.deleteObject(this.key(fileId));
    } catch (error) {
      this.log.error(`Failed to delete file: ${error}`);
      if (error instanceof Error) {
        throw new FileNotFoundError("Error deleting file", { cause: error });
      }
      throw error;
    }
  }

  public async list(bucketName: string): Promise<string[]> {
    this.log.trace(`Listing files in bucket '${bucketName}'...`);
    const client = this.getClient(bucketName);
    const tenantId = this.alepha.store.get(currentTenantAtom)?.id;
    const prefix = tenantId ? `${tenantId}/` : undefined;
    // Flat, single-page listing (~1000 keys). Not a search API.
    const objects = await client.listObjects(undefined, prefix);
    if (!objects) return [];
    return objects.map((object) =>
      prefix && object.Key.startsWith(prefix)
        ? object.Key.slice(prefix.length)
        : object.Key,
    );
  }

  public async deleteMany(
    bucketName: string,
    fileIds: string[],
  ): Promise<void> {
    if (fileIds.length === 0) return;
    this.log.trace(
      `Deleting ${fileIds.length} files from bucket '${bucketName}'...`,
    );
    const client = this.getClient(bucketName);
    // S3 DeleteObjects caps at 1000 keys per request.
    for (let i = 0; i < fileIds.length; i += 1000) {
      const keys = fileIds.slice(i, i + 1000).map((id) => this.key(id));
      try {
        // bun:s3 client exposes a per-key deleteObject; some SDKs also expose
        // deleteObjects(keys: string[]). Prefer batch when available.
        const batch = (
          client as unknown as {
            deleteObjects?: (keys: string[]) => Promise<unknown>;
          }
        ).deleteObjects;
        if (typeof batch === "function") {
          await batch.call(client, keys);
        } else {
          await Promise.all(keys.map((key) => client.deleteObject(key)));
        }
      } catch (error) {
        this.log.error(`Failed to delete files: ${error}`);
        if (error instanceof Error) {
          throw new FileNotFoundError("Error deleting files", { cause: error });
        }
        throw error;
      }
    }
  }
}
