import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  DeleteObjectCommand,
  DeleteObjectsCommand,
  ListObjectsV2Command,
  PutObjectCommand,
  S3Client,
} from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { constants } from 'fs';
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, posix } from 'path';
import { MediaStorageService } from '../../common/media/media-storage.service';

@Injectable()
export class ManagedStorageService implements OnModuleDestroy {
  private s3Client: S3Client | null = null;

  constructor(
    private readonly configService: ConfigService,
    private readonly mediaStorageService: MediaStorageService,
  ) {}

  async saveFile(params: {
    folderSegments: string[];
    extension: string;
    buffer: Buffer;
    contentType?: string;
    fileNamePrefix?: string;
  }): Promise<string> {
    const fileName = `${params.fileNamePrefix ?? 'file'}-${randomUUID()}${params.extension}`;
    const provider = this.getProvider();
    const basePath = this.getBasePath();
    const normalizedSegments = params.folderSegments.map((segment) =>
      segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
    );
    const objectKey = posix.join(basePath, ...normalizedSegments, fileName);

    if (provider === 's3') {
      const upload = await this.mediaStorageService.uploadFile(
        {
          buffer: params.buffer,
          originalname: fileName,
          mimetype: params.contentType,
          size: params.buffer.length,
        },
        posix.join(...normalizedSegments),
      );
      // TODO: Store upload.key beside the URL once media schemas include storage key fields.
      return upload.url;
    }

    const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1));
    await mkdir(uploadDir, { recursive: true });
    await writeFile(join(process.cwd(), ...objectKey.split('/')), params.buffer);
    return `/${objectKey}`;
  }

  async saveFiles(params: {
    folderSegments: string[];
    files: Array<{
      relativePath: string;
      buffer: Buffer;
      contentType?: string;
    }>;
  }): Promise<Record<string, string>> {
    if (!params.files.length) {
      return {};
    }

    const provider = this.getProvider();
    const basePath = this.getBasePath();
    const normalizedSegments = params.folderSegments.map((segment) =>
      segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
    );
    const entries = params.files.map((file) => {
      const relativePath = this.normalizeRelativePath(file.relativePath);
      const objectKey = posix.join(basePath, ...normalizedSegments, relativePath);

      return {
        ...file,
        relativePath,
        objectKey,
      };
    });

    if (provider === 's3') {
      const urls: Record<string, string> = {};

      for (const entry of entries) {
        const upload = await this.mediaStorageService.uploadFileToKey(
          {
            buffer: entry.buffer,
            originalname: entry.relativePath,
            mimetype: entry.contentType,
            size: entry.buffer.length,
          },
          entry.objectKey,
        );
        // TODO: Store upload.key beside the URL once media schemas include storage key fields.
        urls[entry.relativePath] = upload.url;
      }

      return urls;
    }

    const urls: Record<string, string> = {};
    for (const entry of entries) {
      const targetPath = join(process.cwd(), ...entry.objectKey.split('/'));
      await mkdir(dirname(targetPath), { recursive: true });
      await writeFile(targetPath, entry.buffer);
      urls[entry.relativePath] = `/${entry.objectKey}`;
    }

    return urls;
  }

  async deleteFile(fileUrl?: string): Promise<void> {
    if (!fileUrl) {
      return;
    }

    if (this.getProvider() === 's3') {
      const objectKey = this.resolveS3ObjectKey(fileUrl);
      if (!objectKey) {
        return;
      }

      await this.mediaStorageService.deleteFile(objectKey);
      return;
    }

    const relativePath = this.resolveLocalRelativePath(fileUrl);
    if (!relativePath || relativePath.includes('..')) {
      return;
    }

    try {
      await unlink(join(process.cwd(), ...relativePath.split('/')));
    } catch {
      // Ignore cleanup failures for already-missing files.
    }
  }

  resolveLocalFilePath(fileUrl?: string): string | null {
    if (!fileUrl || this.getProvider() !== 'local') {
      return null;
    }

    const relativePath = this.resolveLocalRelativePath(fileUrl);
    if (!relativePath || relativePath.includes('..')) {
      return null;
    }

    return join(process.cwd(), ...relativePath.split('/'));
  }

  async deleteContainingDirectory(fileUrl?: string): Promise<void> {
    if (!fileUrl) {
      return;
    }

    if (this.getProvider() === 's3') {
      const objectKey = this.resolveS3ObjectKey(fileUrl);
      if (!objectKey) {
        return;
      }

      const prefix = posix.dirname(objectKey).replace(/\/?$/, '/');
      const basePrefix = `${this.getBasePath()}/`;
      if (!prefix.startsWith(basePrefix) || prefix === basePrefix) {
        return;
      }

      await this.deleteS3Prefix(prefix);
      return;
    }

    const relativePath = this.resolveLocalRelativePath(fileUrl);
    if (!relativePath || relativePath.includes('..')) {
      return;
    }

    const directoryPath = dirname(relativePath);
    const basePath = this.getBasePath();
    if (!directoryPath || directoryPath === '.' || directoryPath === basePath) {
      return;
    }

    await rm(join(process.cwd(), ...directoryPath.split('/')), { recursive: true, force: true });
  }

  async getHealth(): Promise<Record<string, unknown>> {
    const provider = this.getProvider();
    const basePath = this.getBasePath();
    const storagePublicBaseUrl = (
      this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    const publicBaseUrl = (
      this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    const health: Record<string, unknown> = {
      provider,
      storageProvider: provider,
      basePath,
      storageBasePath: basePath,
      publicPath: `/${basePath}`,
      uploadsPublicPath: `/${basePath}`,
      publicBaseUrlConfigured: !!publicBaseUrl,
      publicBaseUrl,
      storagePublicBaseUrlConfigured: !!storagePublicBaseUrl,
      storagePublicBaseUrl,
      isLocalStorage: provider === 'local',
      isS3Configured: provider === 's3' ? this.hasS3Configuration() : false,
      s3Configured: provider === 's3' ? this.hasS3Configuration() : undefined,
    };

    if (provider === 'local') {
      const uploadDir = join(process.cwd(), ...basePath.split('/'));
      const probePath = join(uploadDir, `.media-health-${randomUUID()}.tmp`);
      let writable = false;
      let error = '';

      try {
        await mkdir(uploadDir, { recursive: true });
        await writeFile(probePath, 'ok');
        await unlink(probePath);
        writable = true;
      } catch (probeError) {
        error = probeError instanceof Error ? probeError.message : 'unknown local storage error';
        try {
          await unlink(probePath);
        } catch {
          // Ignore cleanup failure for a failed probe.
        }
      }

      const exists = await this.pathExists(uploadDir);
      const readable = await this.canAccess(uploadDir, constants.R_OK);
      health.local = {
        exists,
        writable,
        readable,
        error,
      };
      health.uploadPathExists = exists;
      health.uploadPathReadable = readable;
      health.uploadPathWritable = writable;
    } else if (provider === 's3') {
      health.s3 = await this.getS3Health();
    }

    return health;
  }

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

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

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

  private normalizeRelativePath(relativePath: string): string {
    const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
    if (
      !normalized ||
      normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
    ) {
      throw new BadRequestException('Invalid managed file path');
    }

    return normalized;
  }

  private getS3Bucket(): 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 getS3Client(): S3Client {
    if (this.s3Client) {
      return this.s3Client;
    }

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

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

    this.s3Client = new S3Client({
      region,
      endpoint,
      forcePathStyle,
      credentials: {
        accessKeyId,
        secretAccessKey,
      },
    });

    return this.s3Client;
  }

  private resolvePublicUrl(objectKey: string): string {
    const publicBaseUrl = (
      this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    if (publicBaseUrl) {
      return `${publicBaseUrl}/${objectKey}`;
    }

    const endpoint = (
      this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    const bucket = this.getS3Bucket();
    const forcePathStyle =
      this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;

    if (!endpoint) {
      throw new BadRequestException('storage.publicBaseUrl or storage.s3.endpoint is required');
    }

    return forcePathStyle ? `${endpoint}/${bucket}/${objectKey}` : `${endpoint}/${objectKey}`;
  }

  private resolveLocalRelativePath(fileUrl: string): string | null {
    const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
    if (!normalizedUrl.startsWith('/')) {
      return null;
    }

    const expectedPrefix = `/${this.getBasePath()}/`;
    if (!normalizedUrl.startsWith(expectedPrefix) && normalizedUrl !== `/${this.getBasePath()}`) {
      return null;
    }

    return normalizedUrl.replace(/^\/+/, '');
  }

  private resolveS3ObjectKey(fileUrl: string): string | null {
    const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
    const publicBaseUrl = (
      this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
    ).replace(/\/$/, '');

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

    const endpoint = (
      this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
    ).replace(/\/$/, '');
    const bucket = this.getS3Bucket();
    const forcePathStyle =
      this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;

    if (endpoint && normalizedUrl.startsWith(`${endpoint}/`)) {
      const pathPart = normalizedUrl.slice(endpoint.length + 1);
      if (forcePathStyle) {
        const expectedPrefix = `${bucket}/`;
        return pathPart.startsWith(expectedPrefix) ? pathPart.slice(expectedPrefix.length) : null;
      }
      return pathPart;
    }

    return null;
  }

  private hasS3Configuration(): boolean {
    return !!(
      (this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '') &&
      (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '') &&
      (this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '') &&
      (this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '')
    );
  }

  private async getS3Health(): Promise<Record<string, unknown>> {
    const configured = this.hasS3Configuration();
    const writeTestEnabled =
      this.configService.get<boolean>('storage.s3.healthWriteTestEnabled', { infer: true }) ??
      false;
    const health: Record<string, unknown> = {
      configured,
      bucket: this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '',
      endpointConfigured: !!(
        this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
      ),
      publicBaseUrlConfigured: !!(
        this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
      ),
      reachable: false,
      writeTestEnabled,
      writable: undefined,
      error: '',
    };

    if (!configured) {
      health.error = 'S3 storage settings are not fully configured';
      return health;
    }

    try {
      const client = this.getS3Client();
      await client.send(
        new ListObjectsV2Command({
          Bucket: this.getS3Bucket(),
          Prefix: `${this.getBasePath()}/`,
          MaxKeys: 1,
        }),
      );
      health.reachable = true;

      if (writeTestEnabled) {
        const testKey = posix.join(this.getBasePath(), `.media-health-${randomUUID()}.tmp`);
        await client.send(
          new PutObjectCommand({
            Bucket: this.getS3Bucket(),
            Key: testKey,
            Body: 'ok',
            ContentType: 'text/plain',
            CacheControl: 'no-store',
          }),
        );
        await client.send(
          new DeleteObjectCommand({
            Bucket: this.getS3Bucket(),
            Key: testKey,
          }),
        );
        health.writable = true;
      }
    } catch (error) {
      health.error = error instanceof Error ? error.message : 'unknown S3 health error';
      if (writeTestEnabled) {
        health.writable = false;
      }
    }

    return health;
  }

  private resolveCacheControl(objectKey: string): string {
    const extension = objectKey.split('?')[0].split('#')[0].toLowerCase().split('.').pop() ?? '';
    if (extension === 'm3u8') {
      return 'public, max-age=300';
    }

    return 'public, max-age=31536000, immutable';
  }

  private async canAccess(path: string, mode: number): Promise<boolean> {
    try {
      await access(path, mode);
      return true;
    } catch {
      return false;
    }
  }

  private async pathExists(path: string): Promise<boolean> {
    try {
      await stat(path);
      return true;
    } catch {
      return false;
    }
  }

  private async deleteS3Prefix(prefix: string): Promise<void> {
    const client = this.getS3Client();
    const bucket = this.getS3Bucket();
    let continuationToken: string | undefined;

    do {
      const listed = await client.send(
        new ListObjectsV2Command({
          Bucket: bucket,
          Prefix: prefix,
          ContinuationToken: continuationToken,
        }),
      );

      const keys =
        listed.Contents?.map((item) => item.Key).filter((key): key is string => !!key) ?? [];
      if (keys.length) {
        await client.send(
          new DeleteObjectsCommand({
            Bucket: bucket,
            Delete: {
              Objects: keys.map((key) => ({ Key: key })),
              Quiet: true,
            },
          }),
        );
      }

      continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined;
    } while (continuationToken);
  }
}
