import type { R2Bucket } from "@cloudflare/workers-types";
import {
  $env,
  $hook,
  $inject,
  Alepha,
  AlephaError,
  type FileLike,
  z,
} from "alepha";
import { CryptoProvider } from "alepha/crypto";
import { $logger } from "alepha/logger";
import { currentTenantAtom } from "alepha/security";
import { FileNotFoundError } from "../errors/FileNotFoundError.ts";
import { $bucket } from "../primitives/$bucket.ts";
import type { FileStorageProvider } from "./FileStorageProvider.ts";

// ---------------------------------------------------------------------------------------------------------------------

/**
 * Cloudflare R2 storage provider.
 *
 * Uses a single R2 bucket binding for all $bucket primitives.
 * Files are organized as: {APP_NAME}/{bucketName}/{fileId}
 *
 * **Required environment variables:**
 * - `R2_BUCKET_NAME` - The actual R2 bucket name in Cloudflare
 *
 * **Optional (uses core Alepha env):**
 * - `APP_NAME` - Prefix for all files (for multi-app setups sharing one R2 bucket)
 *
 * @example
 * ```bash
 * # .env
 * APP_NAME=myapp        # optional, used as prefix
 * R2_BUCKET_NAME=storage
 * ```
 *
 * @example
 * ```toml
 * # wrangler.toml - automatically generated by alepha build
 * [[r2_buckets]]
 * binding = "R2"
 * bucket_name = "storage"
 * ```
 *
 * @example
 * ```ts
 * // Define buckets with validation rules
 * const avatars = $bucket({
 *   name: "avatars",
 *   maxFileSize: 5 * 1024 * 1024,
 *   allowedMimeTypes: ["image/*"],
 * });
 *
 * const documents = $bucket({
 *   name: "documents",
 *   maxFileSize: 50 * 1024 * 1024,
 *   allowedMimeTypes: ["application/pdf"],
 * });
 *
 * // Files stored at: myapp/avatars/uuid.png, myapp/documents/uuid.pdf
 * ```
 */
export class R2FileStorageProvider implements FileStorageProvider {
  protected readonly alepha = $inject(Alepha);
  protected readonly log = $logger();
  protected readonly crypto = $inject(CryptoProvider);
  protected readonly env = $env(
    z.object({
      /**
       * The actual R2 bucket name in Cloudflare.
       */
      R2_BUCKET_NAME: z.string().describe("R2 bucket name in Cloudflare"),
    }),
  );

  protected r2?: R2Bucket;

  /**
   * Get the R2 bucket name from environment.
   */
  public get bucketName(): string {
    return this.env.R2_BUCKET_NAME;
  }

  /**
   * Get the optional prefix from APP_NAME environment variable.
   * Used for multi-app setups sharing the same R2 bucket.
   */
  public get prefix(): string | undefined {
    return this.alepha.env.APP_NAME;
  }

  protected readonly onStart = $hook({
    on: "start",
    handler: async () => {
      const cloudflareEnv = this.alepha.get("cloudflare.env") as
        | Record<string, unknown>
        | undefined;
      if (!cloudflareEnv) {
        throw new AlephaError(
          "Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'.",
        );
      }

      const binding = cloudflareEnv[this.bucketName] as R2Bucket | undefined;
      if (!binding) {
        throw new AlephaError(
          `R2 binding '${this.bucketName}' not found in Cloudflare Workers environment.`,
        );
      }

      this.r2 = binding;

      const prefixStr = this.prefix ? `${this.prefix}/` : "";
      this.log.info(
        `R2 storage ready (bucket: ${this.bucketName}, prefix: ${prefixStr || "(none)"})`,
      );

      for (const bucket of this.alepha.primitives($bucket)) {
        if (bucket.provider !== this) {
          continue;
        }
        this.log.debug(
          `Bucket '${bucket.name}' -> ${prefixStr}${bucket.name}/`,
        );
      }
    },
  });

  public async upload(
    bucketName: string,
    file: FileLike,
    fileId?: string,
  ): Promise<string> {
    const r2 = this.getR2();
    fileId ??= this.createId(file.name);
    const key = this.key(bucketName, fileId);

    this.log.trace(`Uploading '${key}'`);

    const arrayBuffer = await file.arrayBuffer();

    await r2.put(key, arrayBuffer, {
      httpMetadata: {
        contentType: file.type,
      },
      customMetadata: {
        originalName: file.name,
        bucket: bucketName,
      },
    });

    return fileId;
  }

  public async download(bucketName: string, fileId: string): Promise<FileLike> {
    const r2 = this.getR2();
    const key = this.key(bucketName, fileId);

    this.log.trace(`Downloading '${key}'`);

    const object = await r2.get(key);
    if (!object) {
      throw new FileNotFoundError(
        `File '${fileId}' not found in bucket '${bucketName}'.`,
      );
    }

    const originalName = object.customMetadata?.originalName ?? fileId;
    const contentType =
      object.httpMetadata?.contentType ?? "application/octet-stream";

    return {
      name: originalName,
      type: contentType,
      size: object.size,
      lastModified: object.uploaded.getTime(),
      stream: () => object.body as unknown as ReadableStream,
      arrayBuffer: () => object.arrayBuffer(),
      text: () => object.text(),
    };
  }

  public async exists(bucketName: string, fileId: string): Promise<boolean> {
    const r2 = this.getR2();
    const key = this.key(bucketName, fileId);

    this.log.trace(`Checking '${key}'`);

    const object = await r2.head(key);
    return object !== null;
  }

  public async delete(bucketName: string, fileId: string): Promise<void> {
    const r2 = this.getR2();
    const key = this.key(bucketName, fileId);

    this.log.trace(`Deleting '${key}'`);

    // R2's delete doesn't throw if the file doesn't exist,
    // so we check existence first for consistency with other providers
    const exists = await this.exists(bucketName, fileId);
    if (!exists) {
      throw new FileNotFoundError(
        `File '${fileId}' not found in bucket '${bucketName}'.`,
      );
    }

    await r2.delete(key);
  }

  public async deleteMany(
    bucketName: string,
    fileIds: string[],
  ): Promise<void> {
    if (fileIds.length === 0) return;
    const r2 = this.getR2();
    const keys = fileIds.map((id) => this.key(bucketName, id));
    this.log.trace(`Deleting ${keys.length} keys from '${bucketName}'`);
    // R2 binding accepts a string[] and silently ignores missing keys.
    // Chunk at 1000 to stay within the documented batch limit.
    for (let i = 0; i < keys.length; i += 1000) {
      await r2.delete(keys.slice(i, i + 1000));
    }
  }

  public async list(bucketName: string): Promise<string[]> {
    const r2 = this.getR2();
    const keyPrefix = this.key(bucketName, "");
    this.log.trace(`Listing files in '${keyPrefix}'`);
    // Flat, single-page listing (~1000 keys). Not a search API.
    const listed = await r2.list({ prefix: keyPrefix });
    return listed.objects.map((object) => object.key.slice(keyPrefix.length));
  }

  /**
   * Build the full R2 key: {prefix}/{tenantId}/{bucketName}/{fileId}
   *
   * When a tenant is active on the current request/job (`currentTenantAtom`),
   * its id is inserted as a directory so a pooled multi-tenant worker keeps
   * each tenant's objects isolated. No tenant → the historical
   * `{prefix}/{bucketName}/{fileId}` layout is unchanged.
   */
  protected key(bucketName: string, fileId: string): string {
    const parts = [bucketName, fileId];
    const tenantId = this.alepha.store.get(currentTenantAtom)?.id;
    if (tenantId) {
      parts.unshift(tenantId);
    }
    if (this.prefix) {
      parts.unshift(this.prefix);
    }
    return parts.join("/");
  }

  protected getR2(): R2Bucket {
    if (!this.r2) {
      throw new AlephaError("R2 storage not initialized. Call start() first.");
    }
    return this.r2;
  }

  protected createId(filename: string): string {
    const ext = filename.includes(".") ? filename.split(".").pop() : "";
    const id = this.crypto.randomUUID();
    return ext ? `${id}.${ext}` : id;
  }
}
