import { Injectable, Logger, OnModuleInit, ServiceUnavailableException } from "@nestjs/common";
import {
  CreateBucketCommand,
  DeleteObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  PutBucketCorsCommand,
  PutBucketEncryptionCommand,
  PutObjectCommand,
  PutPublicAccessBlockCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
  createBucketInput,
  describeS3Error,
  isMissingBucket,
  optionalEnv,
  s3CorsOrigins,
  s3CustomEndpoint,
} from "./s3-env";

@Injectable()
export class ObjectStorageService implements OnModuleInit {
  private readonly logger = new Logger(ObjectStorageService.name);
  readonly bucket = optionalEnv("S3_BUCKET") ?? "windshieldhub-connecthub-files";
  private readonly endpoint = s3CustomEndpoint();
  private readonly publicEndpoint = optionalEnv("S3_PUBLIC_ENDPOINT") ?? this.endpoint;
  private readonly region = optionalEnv("S3_REGION") ?? "us-west-2";
  private ready = false;
  private readonly credentials = optionalEnv("S3_ACCESS_KEY") && optionalEnv("S3_SECRET_KEY")
    ? { accessKeyId: optionalEnv("S3_ACCESS_KEY")!, secretAccessKey: optionalEnv("S3_SECRET_KEY")! }
    : undefined;
  private readonly client = this.createClient(this.endpoint);
  private readonly signingClient = this.createClient(this.publicEndpoint);

  private createClient(endpoint?: string) {
    return new S3Client({
      region: this.region,
      endpoint,
      forcePathStyle: Boolean(endpoint),
      credentials: this.credentials,
      followRegionRedirects: true,
      // AWS SDK v3 signs CRC32 checksum headers by default. Browsers do not
      // send those on a presigned PUT, so Amazon S3 rejects the upload.
      requestChecksumCalculation: "WHEN_REQUIRED",
      responseChecksumValidation: "WHEN_REQUIRED",
    });
  }

  async onModuleInit() {
    await this.ensureBucket().catch((error) => this.logger.warn(`object storage unavailable during startup: ${describeS3Error(error)}`));
  }

  private async applyCors() {
    await this.client.send(new PutBucketCorsCommand({
      Bucket: this.bucket,
      CORSConfiguration: {
        CORSRules: [{
          AllowedHeaders: ["*"],
          AllowedMethods: ["GET", "HEAD", "PUT"],
          AllowedOrigins: s3CorsOrigins(),
          ExposeHeaders: ["ETag", "etag"],
          MaxAgeSeconds: 3600,
        }],
      },
    })).catch((corsError) => this.logger.warn(
      `could not configure bucket CORS (${describeS3Error(corsError)}). Set it on the bucket in AWS if browser uploads fail.`,
    ));
  }

  /**
   * Tight IAM policies often allow object Put/Get/Delete but not HeadBucket
   * (`s3:ListBucket`). HeadObject on a missing key then returns 403, not 404,
   * so a Put of a tiny sentinel is the check that actually matches this policy.
   */
  private async assertObjectAccess() {
    await this.client.send(new PutObjectCommand({
      Bucket: this.bucket,
      Key: ".connecthub-health",
      Body: "ok",
      ContentType: "text/plain",
    }));
  }

  private async ensureBucket() {
    if (this.ready) return;
    try {
      await this.assertObjectAccess();
    } catch (error: unknown) {
      const autoCreate = (optionalEnv("S3_AUTO_CREATE_BUCKET") ?? (this.endpoint ? "true" : "false")) === "true";
      if (isMissingBucket(error) && autoCreate) {
        await this.client.send(new CreateBucketCommand(createBucketInput(this.bucket, this.region, this.endpoint)));
        if (!this.endpoint) {
          await this.client.send(new PutPublicAccessBlockCommand({
            Bucket: this.bucket,
            PublicAccessBlockConfiguration: {
              BlockPublicAcls: true,
              IgnorePublicAcls: true,
              BlockPublicPolicy: true,
              RestrictPublicBuckets: true,
            },
          })).catch((blockError) => this.logger.warn(`could not block public access on bucket: ${describeS3Error(blockError)}`));
          await this.client.send(new PutBucketEncryptionCommand({
            Bucket: this.bucket,
            ServerSideEncryptionConfiguration: {
              Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } }],
            },
          })).catch((encError) => this.logger.warn(`could not enable bucket encryption: ${describeS3Error(encError)}`));
        }
        await this.assertObjectAccess();
      } else {
        throw error;
      }
    }
    await this.applyCors();
    this.ready = true;
    this.logger.log(`object storage ready (Amazon S3 bucket ${this.bucket} in ${this.region}${this.endpoint ? ` via ${this.endpoint}` : ""})`);
  }

  private async available() {
    try {
      await this.ensureBucket();
    } catch (error) {
      this.logger.error(`file storage unavailable: ${describeS3Error(error)}`);
      throw new ServiceUnavailableException("file storage is unavailable");
    }
  }

  async putObject(objectKey: string, body: Uint8Array, contentType: string) {
    await this.available();
    await this.client.send(new PutObjectCommand({
      Bucket: this.bucket,
      Key: objectKey,
      Body: body,
      ContentType: contentType,
    }));
  }

  async signedUpload(objectKey: string, contentType: string) {
    await this.available();
    return getSignedUrl(this.signingClient, new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, ContentType: contentType }), { expiresIn: 10 * 60 });
  }

  async signedDownload(objectKey: string, fileName: string, contentType: string) {
    await this.available();
    return getSignedUrl(this.signingClient, new GetObjectCommand({
      Bucket: this.bucket,
      Key: objectKey,
      ResponseContentType: contentType,
      ResponseContentDisposition: `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`,
    }), { expiresIn: 5 * 60 });
  }

  async inspect(objectKey: string) {
    await this.available();
    const head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }));
    const sample = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey, Range: "bytes=0-4095" }));
    return { size: Number(head.ContentLength ?? 0), bytes: new Uint8Array(await sample.Body!.transformToByteArray()) };
  }

  async getObjectBytes(objectKey: string): Promise<Uint8Array> {
    await this.available();
    const object = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
    return new Uint8Array(await object.Body!.transformToByteArray());
  }

  s3Config() {
    return {
      bucket: this.bucket,
      region: this.region,
      endpoint: this.endpoint,
      accessKey: this.credentials?.accessKeyId,
      secret: this.credentials?.secretAccessKey,
    };
  }

  async delete(objectKey: string) {
    await this.available();
    await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
  }
}
