import {
  BadRequestException,
  Injectable,
  OnModuleDestroy,
  ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  DeleteObjectCommand,
  GetObjectCommand,
  PutObjectCommand,
  S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
import { posix } from 'path';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import {
  DirectUploadFolder,
  DirectUploadMimeType,
  resolveDirectUploadPolicy,
} from './direct-upload.policy';
import { getFileExtension } from './allowed-media';

export type MediaStorageUploadFile = {
  buffer: Buffer;
  originalname?: string;
  mimetype?: string;
  size?: number;
};

export type MediaStorageUploadResult = {
  key: string;
  url: string;
  mimeType: string;
  size: number;
  originalName: string;
};

export type CreatePresignedMediaUploadInput = {
  userId: string;
  folder: DirectUploadFolder;
  mimeType: DirectUploadMimeType;
  size: number;
};

export type PresignedMediaUploadResult = {
  method: 'PUT';
  uploadUrl: string;
  storageKey: string;
  mediaUrl: string;
  mimeType: DirectUploadMimeType;
  size: number;
  requiredHeaders: { 'Content-Type': DirectUploadMimeType };
  expiresIn: number;
  expiresAt: string;
};

const DIRECT_UPLOAD_EXPIRES_SECONDS = 15 * 60;

@Injectable()
export class MediaStorageService implements OnModuleDestroy {
  private s3Client: S3Client | null = null;
  private readonly signedUrlCache = new Map<string, { url: string; refreshAfterEpochMs: number }>();

  constructor(
    private readonly configService: ConfigService,
    private readonly appLogger: AppLoggerService,
  ) {}

  async uploadFile(
    file: MediaStorageUploadFile,
    folder: string,
  ): Promise<MediaStorageUploadResult> {
    const extension = getFileExtension(file.originalname);
    const fileName = `media-${randomUUID()}${extension}`;
    const key = posix.join(this.getBasePath(), this.normalizeFolder(folder), fileName);

    return this.uploadFileToKey(file, key);
  }

  async uploadFileToKey(
    file: MediaStorageUploadFile,
    key: string,
  ): Promise<MediaStorageUploadResult> {
    const normalizedKey = this.normalizeKey(key);
    if (!normalizedKey) {
      throw new BadRequestException('Invalid media storage key');
    }

    const mimeType = file.mimetype ?? 'application/octet-stream';

    await this.getS3Client().send(
      new PutObjectCommand({
        Bucket: this.getBucket(),
        Key: normalizedKey,
        Body: file.buffer,
        ContentType: mimeType,
        CacheControl: this.resolveCacheControl(normalizedKey),
      }),
    );

    return {
      key: normalizedKey,
      url: this.getPublicUrl(normalizedKey),
      mimeType,
      size: file.size ?? file.buffer.length,
      originalName: file.originalname ?? '',
    };
  }

  async createPresignedUpload(
    input: CreatePresignedMediaUploadInput,
  ): Promise<PresignedMediaUploadResult> {
    if (this.getProvider() !== 's3') {
      throw new ServiceUnavailableException(
        'Direct media uploads are available only when S3 storage is enabled',
      );
    }

    const policy = resolveDirectUploadPolicy(input.folder, input.mimeType);
    if (!policy) {
      throw new BadRequestException('Media type is not allowed for this upload folder');
    }
    if (!Number.isSafeInteger(input.size) || input.size < 1) {
      throw new BadRequestException('Media size must be a positive integer');
    }
    if (input.size > policy.maxSizeBytes) {
      throw new BadRequestException(
        `Media size exceeds the ${policy.maxSizeBytes}-byte limit for this upload folder`,
      );
    }

    const userSegment = this.normalizeUserSegment(input.userId);
    const fileName = `media-${randomUUID()}${policy.extension}`;
    const key = posix.join(this.getBasePath(), input.folder, userSegment, fileName);
    const uploadUrl = await getSignedUrl(
      this.getS3Client(),
      new PutObjectCommand({
        Bucket: this.getBucket(),
        Key: key,
        ContentLength: input.size,
        ContentType: input.mimeType,
        CacheControl: this.resolveCacheControl(key),
        Metadata: {
          owner: userSegment,
          'upload-source': 'direct',
        },
      }),
      {
        expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
        signableHeaders: new Set(['content-type']),
      },
    );
    const expiresAt = new Date(Date.now() + DIRECT_UPLOAD_EXPIRES_SECONDS * 1000).toISOString();

    return {
      method: 'PUT',
      uploadUrl,
      storageKey: key,
      mediaUrl: this.getPublicUrl(key),
      mimeType: input.mimeType,
      size: input.size,
      requiredHeaders: { 'Content-Type': input.mimeType },
      expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
      expiresAt,
    };
  }

  async deleteFile(key: string): Promise<void> {
    const normalizedKey = this.normalizeKey(key);
    if (!normalizedKey) {
      return;
    }

    await this.getS3Client().send(
      new DeleteObjectCommand({
        Bucket: this.getBucket(),
        Key: normalizedKey,
      }),
    );
  }

  getPublicUrl(key: string): string {
    const normalizedKey = this.normalizeKey(key);
    if (!normalizedKey) {
      throw new BadRequestException('Invalid media storage key');
    }

    const publicBaseUrl = this.getPublicBaseUrl();
    if (publicBaseUrl) {
      return `${publicBaseUrl}/${normalizedKey}`;
    }

    const endpoint = this.getEndpoint();
    const bucket = this.getBucket();
    return this.getForcePathStyle()
      ? `${endpoint}/${bucket}/${normalizedKey}`
      : `${endpoint}/${normalizedKey}`;
  }

  resolveKeyFromUrl(fileUrl: string): string | null {
    const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
    const managedPathPrefix = `/${this.getBasePath()}/`;
    if (normalizedUrl.startsWith(managedPathPrefix)) {
      return this.normalizeKey(normalizedUrl.slice(1));
    }

    const publicBaseUrl = this.getPublicBaseUrl();

    if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
      return this.normalizeS3UrlPath(normalizedUrl.slice(publicBaseUrl.length + 1));
    }

    const endpoint = this.getEndpoint();
    if (!endpoint || !normalizedUrl.startsWith(`${endpoint}/`)) {
      return this.resolveKeyFromApplicationUrl(normalizedUrl, managedPathPrefix);
    }

    return this.normalizeS3UrlPath(normalizedUrl.slice(endpoint.length + 1));
  }

  shouldSignResponseUrls(): boolean {
    return (
      this.getProvider() === 's3' &&
      this.configService.get<string>('storage.mediaAccessMode', { infer: true }) === 'signed'
    );
  }

  async resolveResponseUrl(fileUrl: string): Promise<string> {
    if (
      !this.shouldSignResponseUrls() ||
      !fileUrl.trim() ||
      this.isAlreadyPresignedS3Url(fileUrl)
    ) {
      return fileUrl;
    }

    const key = this.resolveKeyFromUrl(fileUrl);
    if (!key) {
      return fileUrl;
    }

    if (key.toLowerCase().endsWith('.m3u8')) {
      return this.getHlsPlaylistUrl(key);
    }

    return this.getSignedObjectUrl(key);
  }

  async getSignedObjectUrl(key: string): Promise<string> {
    const normalizedKey = this.normalizeKey(key);
    if (!normalizedKey) {
      return '';
    }

    const cached = this.signedUrlCache.get(normalizedKey);
    if (cached && cached.refreshAfterEpochMs > Date.now()) {
      return cached.url;
    }

    const bucket = this.getBucket();
    const expiresIn = this.getSignedUrlExpiresSeconds();

    try {
      const url = await getSignedUrl(
        this.getS3Client(),
        new GetObjectCommand({
          Bucket: bucket,
          Key: normalizedKey,
        }),
        { expiresIn },
      );
      const refreshBufferSeconds = Math.min(60, Math.max(1, Math.floor(expiresIn / 10)));
      this.cacheSignedUrl(normalizedKey, url, expiresIn - refreshBufferSeconds);
      return url;
    } catch (error) {
      const errorName = error instanceof Error ? error.name : 'UnknownError';
      const errorMessage = error instanceof Error ? error.message : String(error);
      this.appLogger.error(
        {
          event: 's3_presigned_url_failed',
          key: normalizedKey,
          bucket,
          errorName,
          errorMessage,
        },
        error instanceof Error ? error.stack : undefined,
        'MediaStorageService',
      );
      return '';
    }
  }

  async getObjectText(key: string): Promise<string> {
    const normalizedKey = this.normalizeKey(key);
    if (!normalizedKey) {
      throw new BadRequestException('Invalid media storage key');
    }

    const response = await this.getS3Client().send(
      new GetObjectCommand({
        Bucket: this.getBucket(),
        Key: normalizedKey,
      }),
    );
    if (!response.Body) {
      throw new Error(`S3 object body is empty for key "${normalizedKey}"`);
    }

    return response.Body.transformToString('utf-8');
  }

  getHlsPlaylistUrl(key: string): string {
    const normalizedKey = this.normalizeKey(key);
    if (!normalizedKey || !normalizedKey.toLowerCase().endsWith('.m3u8')) {
      throw new BadRequestException('Invalid HLS playlist key');
    }

    const publicBaseUrl = (
      this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    const globalPrefix = (
      this.configService.get<string>('globalPrefix', { infer: true }) ?? 'api/v1'
    ).replace(/^\/+|\/+$/g, '');
    const encodedKey = normalizedKey
      .split('/')
      .map((segment) => encodeURIComponent(segment))
      .join('/');
    const path = `/${globalPrefix}/media/hls/${encodedKey}`;

    const baseUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : path;
    if (!this.shouldSignResponseUrls()) return baseUrl;
    const expires = Math.floor(Date.now() / 1000) + this.getSignedUrlExpiresSeconds();
    const signature = this.signHlsPlaylistKey(normalizedKey, expires);
    return `${baseUrl}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
  }

  isValidHlsPlaylistAccess(key: string, expiresValue?: string, signature?: string): boolean {
    const normalizedKey = this.normalizeKey(key);
    const expires = Number(expiresValue);
    if (!normalizedKey || !Number.isInteger(expires) || expires <= Math.floor(Date.now() / 1000) || !signature) {
      return false;
    }
    const expected = Buffer.from(this.signHlsPlaylistKey(normalizedKey, expires));
    const supplied = Buffer.from(signature);
    return expected.length === supplied.length && timingSafeEqual(expected, supplied);
  }

  getBucketName(): string {
    return this.getBucket();
  }

  isManagedKey(key: string): boolean {
    const normalizedKey = this.normalizeKey(key);
    return !!normalizedKey && normalizedKey.startsWith(`${this.getBasePath()}/`);
  }

  onModuleDestroy(): void {
    this.s3Client = null;
    this.signedUrlCache.clear();
  }

  private signHlsPlaylistKey(key: string, expires: number): string {
    const secret =
      this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ||
      this.configService.get<string>('jwt.accessSecret', { infer: true }) ||
      '';
    return createHmac('sha256', secret).update(`${key}:${expires}`).digest('base64url');
  }

  private getS3Client(): S3Client {
    if (this.s3Client) {
      return this.s3Client;
    }

    const endpoint = this.getEndpoint();
    const accessKeyId =
      this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '';
    const secretAccessKey =
      this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '';

    if (!endpoint || !accessKeyId || !secretAccessKey) {
      throw new BadRequestException('S3 storage settings are not fully configured');
    }

    this.s3Client = new S3Client({
      region: this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto',
      endpoint,
      forcePathStyle: this.getForcePathStyle(),
      requestChecksumCalculation: 'WHEN_REQUIRED',
      credentials: {
        accessKeyId,
        secretAccessKey,
      },
    });

    return this.s3Client;
  }

  private getBasePath(): string {
    return (this.configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads')
      .replace(/\\/g, '/')
      .replace(/^\/+|\/+$/g, '');
  }

  private getProvider(): 'local' | 's3' {
    return (
      (this.configService.get<string>('storage.provider', { infer: true }) as
        | 'local'
        | 's3'
        | undefined) ?? 'local'
    );
  }

  private getBucket(): string {
    const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
    if (!bucket) {
      throw new BadRequestException('S3 bucket is not configured');
    }
    return bucket;
  }

  private getEndpoint(): string {
    return (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
      /\/$/,
      '',
    );
  }

  private getPublicBaseUrl(): string {
    return (this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
      /\/$/,
      '',
    );
  }

  private getForcePathStyle(): boolean {
    return this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
  }

  private getSignedUrlExpiresSeconds(): number {
    const configured =
      this.configService.get<number>('storage.signedUrlExpiresSeconds', { infer: true }) ?? 86400;
    return Math.min(604800, Math.max(1, Math.floor(configured)));
  }

  private resolveKeyFromApplicationUrl(fileUrl: string, managedPathPrefix: string): string | null {
    const applicationBaseUrl = (
      this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    if (!applicationBaseUrl || !fileUrl.startsWith(`${applicationBaseUrl}/`)) {
      return null;
    }

    try {
      const pathname = new URL(fileUrl).pathname;
      return pathname.startsWith(managedPathPrefix) ? this.normalizeKey(pathname.slice(1)) : null;
    } catch {
      return null;
    }
  }

  private normalizeS3UrlPath(pathPart: string): string | null {
    if (!this.getForcePathStyle()) {
      return this.normalizeKey(pathPart);
    }

    const bucketPrefix = `${this.getBucket()}/`;
    return this.normalizeKey(
      pathPart.startsWith(bucketPrefix) ? pathPart.slice(bucketPrefix.length) : pathPart,
    );
  }

  private isAlreadyPresignedS3Url(fileUrl: string): boolean {
    try {
      const searchParams = new URL(fileUrl).searchParams;
      return (
        searchParams.has('X-Amz-Algorithm') &&
        searchParams.has('X-Amz-Credential') &&
        searchParams.has('X-Amz-Signature')
      );
    } catch {
      return false;
    }
  }

  private cacheSignedUrl(key: string, url: string, refreshAfterSeconds: number): void {
    if (this.signedUrlCache.size >= 10000) {
      const oldestKey = this.signedUrlCache.keys().next().value as string | undefined;
      if (oldestKey) {
        this.signedUrlCache.delete(oldestKey);
      }
    }

    this.signedUrlCache.set(key, {
      url,
      refreshAfterEpochMs: Date.now() + Math.max(1, refreshAfterSeconds) * 1000,
    });
  }

  private normalizeFolder(folder: string): string {
    const normalized = folder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
    if (
      !normalized ||
      normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
    ) {
      throw new BadRequestException('Invalid media folder');
    }
    return normalized;
  }

  private normalizeUserSegment(userId: string): string {
    const normalized = userId.trim();
    if (!/^[a-zA-Z0-9_-]{1,128}$/.test(normalized)) {
      throw new BadRequestException('Invalid media owner id');
    }
    return normalized;
  }

  private normalizeKey(key: string): string {
    const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
    if (
      !normalized ||
      normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
    ) {
      return '';
    }
    return normalized;
  }

  private resolveCacheControl(key: string): string {
    return key.toLowerCase().endsWith('.m3u8')
      ? 'public, max-age=300'
      : 'public, max-age=31536000, immutable';
  }
}
