import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CreateMultipartUploadCommand,
  DeleteObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  ListObjectsV2Command,
  S3Client,
  UploadPartCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { BadGatewayException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class S3Service {
  private readonly client: S3Client;
  private readonly bucket: string;
  private readonly region: string;
  private readonly cloudFrontDomain?: string;

  constructor(private readonly config: ConfigService) {
    this.region = this.config.getOrThrow<string>('AWS_REGION');
    this.bucket = this.config.getOrThrow<string>('AWS_S3_BUCKET');
    this.cloudFrontDomain = this.config.get<string>('CLOUDFRONT_DOMAIN')?.replace(/^https?:\/\//, '').replace(/\/$/, '');
    this.client = new S3Client({
      region: this.region,
      credentials: {
        accessKeyId: this.config.getOrThrow<string>('AWS_ACCESS_KEY_ID'),
        secretAccessKey: this.config.getOrThrow<string>('AWS_SECRET_ACCESS_KEY'),
      },
    });
  }

  async createMultipartUpload(key: string, contentType: string) {
    const result = await this.withS3Errors(() =>
      this.client.send(new CreateMultipartUploadCommand({ Bucket: this.bucket, Key: key, ContentType: contentType })),
    );
    if (!result.UploadId) throw new Error('S3 did not return an upload id');
    return result.UploadId;
  }

  async getPresignedPartUrl(key: string, uploadId: string, partNumber: number) {
    return getSignedUrl(
      this.client,
      new UploadPartCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber }),
      { expiresIn: 3600 },
    );
  }

  async completeMultipartUpload(key: string, uploadId: string, parts: { partNumber: number; etag: string }[]) {
    await this.withS3Errors(() =>
      this.client.send(new CompleteMultipartUploadCommand({
        Bucket: this.bucket,
        Key: key,
        UploadId: uploadId,
        MultipartUpload: {
          Parts: parts
            .sort((a, b) => a.partNumber - b.partNumber)
            .map((part) => ({ ETag: part.etag, PartNumber: part.partNumber })),
        },
      })),
    );
    return this.getPublicUrl(key);
  }

  async abortMultipartUpload(key: string, uploadId: string) {
    await this.withS3Errors(() =>
      this.client.send(new AbortMultipartUploadCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId })),
    );
  }

  async deleteObject(key: string) {
    await this.withS3Errors(() => this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })));
  }

  async listZipObjects(prefix = '') {
    const objects: { key: string; size: number; lastModified?: Date }[] = [];
    let continuationToken: string | undefined;

    do {
      const result = await this.withS3Errors(() =>
        this.client.send(new ListObjectsV2Command({
          Bucket: this.bucket,
          Prefix: prefix,
          ContinuationToken: continuationToken,
        })),
      );
      for (const object of result.Contents ?? []) {
        if (!object.Key || object.Key.endsWith('/') || !object.Key.toLowerCase().endsWith('.zip')) continue;
        if (object.Key.split('/').some((part) => part.startsWith('._'))) continue;
        objects.push({ key: object.Key, size: object.Size ?? 0, lastModified: object.LastModified });
      }
      continuationToken = result.NextContinuationToken;
    } while (continuationToken);

    return objects;
  }

  async getObjectContentType(key: string) {
    const result = await this.withS3Errors(() =>
      this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })),
    );
    return result.ContentType || 'application/zip';
  }

  getPublicUrl(key: string) {
    if (this.isCloudFrontEnabled()) return `https://${this.cloudFrontDomain}/${encodeURI(key)}`;
    return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${encodeURI(key)}`;
  }

  async getDownloadUrl(key: string) {
    if (this.isCloudFrontEnabled()) return this.getPublicUrl(key);
    if (this.config.get<string>('AWS_S3_PUBLIC') === 'true') return this.getPublicUrl(key);
    const expiresIn = this.config.get<number>('AWS_SIGNED_URL_EXPIRES_IN', 3600);
    return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn });
  }

  private isCloudFrontEnabled() {
    return Boolean(this.cloudFrontDomain) && this.config.get<string>('CLOUDFRONT_ENABLED') === 'true';
  }

  private async withS3Errors<T>(operation: () => Promise<T>): Promise<T> {
    try {
      return await operation();
    } catch (error) {
      throw this.toS3Exception(error);
    }
  }

  private toS3Exception(error: unknown) {
    const message = error instanceof Error ? error.message : 'S3 request failed';
    const regionHint =
      typeof error === 'object' && error && '$response' in error
        ? undefined
        : typeof error === 'object' && error && 'Region' in error
          ? String((error as { Region?: string }).Region)
          : undefined;
    const isRegionMismatch =
      message.includes('specified endpoint') || message.includes('PermanentRedirect') || message.includes('301');

    if (isRegionMismatch) {
      throw new BadGatewayException({
        code: 'S3_REGION_MISMATCH',
        message: `S3 bucket "${this.bucket}" is not in AWS_REGION "${this.region}". Set AWS_REGION to the bucket region${regionHint ? ` (${regionHint})` : ''}.`,
      });
    }

    throw new BadGatewayException({ code: 'S3_REQUEST_FAILED', message });
  }
}
