import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { S3Service } from '../aws/s3.service';
import { CacheKeys } from '../common/utils/cache-keys';
import { buildMapS3Key } from '../common/utils/s3-key';
import { pagination, safeSort } from '../common/utils/pagination';
import { PrismaService } from '../prisma/prisma.service';
import { S3CleanupQueue } from '../queues/s3-cleanup.queue';
import { RedisService } from '../redis/redis.service';
import {
  AbortUploadDto,
  CompleteUploadDto,
  InitiateUploadDto,
  ListOfflineMapsDto,
  PresignedPartDto,
  SyncS3MapsDto,
  UpdateOfflineMapDto,
} from './dto';

const ZIP_MIME_TYPES = new Set(['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']);
const SORT_FIELDS = ['createdAt', 'updatedAt', 'countryName', 'stateName', 'status', 'uploadStatus'] as const;

@Injectable()
export class OfflineMapsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly s3: S3Service,
    private readonly redis: RedisService,
    private readonly cleanupQueue: S3CleanupQueue,
  ) {}

  async initiateUpload(dto: InitiateUploadDto, userId: string, replaceId?: string) {
    this.validateZip(dto);
    const s3Key = buildMapS3Key(dto.countryName, dto.stateName, dto.fileName);
    const uploadId = await this.s3.createMultipartUpload(s3Key, dto.mimeType);
    const map = replaceId
      ? await this.createReplacementHistory(replaceId, s3Key, uploadId)
      : await this.prisma.offlineMap.create({
          data: {
            countryName: dto.countryName,
            stateName: dto.stateName,
            fileName: dto.fileName,
            fileSize: BigInt(dto.fileSize),
            mimeType: dto.mimeType,
            s3Key,
            uploadedById: userId,
            uploadStatus: 'UPLOADING',
            status: 'INACTIVE',
          },
        });
    return { message: 'Upload initiated successfully', data: { offlineMapId: map.id, uploadId, s3Key } };
  }

  async presignedPart(dto: PresignedPartDto) {
    const url = await this.s3.getPresignedPartUrl(dto.s3Key, dto.uploadId, dto.partNumber);
    return { message: 'Presigned URL generated successfully', data: { partNumber: dto.partNumber, url } };
  }

  async completeUpload(dto: CompleteUploadDto, replacement = false) {
    const current = await this.getActiveRecord(dto.offlineMapId);
    const s3Url = await this.s3.completeMultipartUpload(dto.s3Key, dto.uploadId, dto.parts);
    const oldS3Key = current.s3Key;
    const map = await this.prisma.$transaction(async (tx) => {
      const updated = await tx.offlineMap.update({
        where: { id: dto.offlineMapId },
        data: { s3Key: dto.s3Key, s3Url, uploadStatus: 'COMPLETED', status: 'ACTIVE' },
      });
      await tx.mapUploadHistory.updateMany({
        where: { offlineMapId: dto.offlineMapId, uploadId: dto.uploadId },
        data: { status: 'COMPLETED', newS3Key: dto.s3Key },
      });
      return updated;
    });
    await this.clearMapCache(current.countryName, current.stateName);
    await this.clearMapCache(map.countryName, map.stateName);
    if (replacement && oldS3Key && oldS3Key !== dto.s3Key) await this.cleanupQueue.enqueueDelete(oldS3Key);
    return { message: 'Upload completed successfully', data: this.serializeMap(map) };
  }

  async abortUpload(dto: AbortUploadDto) {
    await this.s3.abortMultipartUpload(dto.s3Key, dto.uploadId);
    const map = await this.prisma.offlineMap.update({
      where: { id: dto.offlineMapId },
      data: { uploadStatus: 'FAILED' },
    });
    await this.prisma.mapUploadHistory.updateMany({
      where: { offlineMapId: dto.offlineMapId, uploadId: dto.uploadId },
      data: { status: 'FAILED' },
    });
    return { message: 'Upload aborted successfully', data: this.serializeMap(map) };
  }

  async syncFromS3(userId: string, dto: SyncS3MapsDto) {
    const prefix = dto.prefix?.trim() ?? '';
    const objects = await this.s3.listZipObjects(prefix);
    const keys = objects.map((object) => object.key);
    const existing = keys.length
      ? await this.prisma.offlineMap.findMany({
          where: { s3Key: { in: keys } },
          select: { s3Key: true },
        })
      : [];
    const existingKeys = new Set(existing.map((map) => map.s3Key).filter((key): key is string => Boolean(key)));
    const missingObjects = objects.filter((object) => !existingKeys.has(object.key));
    const skipped: { key: string; reason: string }[] = [];
    const candidates = missingObjects
      .map((object) => {
        const parsed = this.parseS3MapKey(object.key);
        if (!parsed) {
          skipped.push({ key: object.key, reason: 'S3 key must contain country/state/file.zip' });
          return null;
        }
        return { ...object, ...parsed };
      })
      .filter((item): item is {
        key: string;
        size: number;
        lastModified?: Date;
        countryName: string;
        stateName: string;
        fileName: string;
      } => Boolean(item));

    if (dto.dryRun) {
      return {
        message: 'S3 sync dry run completed',
        data: {
          scanned: objects.length,
          existing: existingKeys.size,
          insertable: candidates.length,
          skipped,
          items: candidates.map((item) => ({
            s3Key: item.key,
            countryName: item.countryName,
            stateName: item.stateName,
            fileName: item.fileName,
            fileSize: item.size,
          })),
        },
      };
    }

    const created = [];
    for (const item of candidates) {
      const mimeType = await this.s3.getObjectContentType(item.key);
      const map = await this.prisma.offlineMap.create({
        data: {
          countryName: item.countryName,
          stateName: item.stateName,
          fileName: item.fileName,
          fileSize: BigInt(item.size),
          mimeType,
          s3Key: item.key,
          s3Url: this.s3.getPublicUrl(item.key),
          uploadedById: userId,
          uploadStatus: 'COMPLETED',
          status: dto.status ?? 'ACTIVE',
          createdAt: item.lastModified,
        },
      });
      created.push(this.serializeMap(map));
      await this.clearMapCache(item.countryName, item.stateName);
    }

    return {
      message: 'S3 maps synced successfully',
      data: {
        scanned: objects.length,
        existing: existingKeys.size,
        created: created.length,
        skipped,
        items: created,
      },
    };
  }

  async list(query: ListOfflineMapsDto) {
    const { page, limit, skip } = pagination(query.page, query.limit);
    const where: Prisma.OfflineMapWhereInput = {
      deletedAt: null,
      countryName: query.countryName ? { contains: query.countryName, mode: 'insensitive' } : undefined,
      stateName: query.stateName ? { contains: query.stateName, mode: 'insensitive' } : undefined,
      status: query.status,
      uploadStatus: query.uploadStatus,
      OR: query.search
        ? ['countryName', 'stateName', 'fileName'].map((field) => ({
            [field]: { contains: query.search, mode: 'insensitive' },
          }))
        : undefined,
    };
    const [items, total] = await this.prisma.$transaction([
      this.prisma.offlineMap.findMany({
        where,
        include: { uploadedBy: { select: { id: true, name: true, email: true } } },
        skip,
        take: limit,
        orderBy: safeSort(query.sortBy, query.sortOrder, SORT_FIELDS),
      }),
      this.prisma.offlineMap.count({ where }),
    ]);
    return { message: 'Offline maps fetched successfully', data: { items: items.map((item) => this.serializeMap(item)), meta: { page, limit, total } } };
  }

  async detail(id: string) {
    return { message: 'Offline map fetched successfully', data: this.serializeMap(await this.getActiveRecord(id)) };
  }

  async update(id: string, dto: UpdateOfflineMapDto) {
    const current = await this.getActiveRecord(id);
    const map = await this.prisma.offlineMap.update({ where: { id }, data: dto });
    await this.clearMapCache(current.countryName, current.stateName);
    await this.clearMapCache(map.countryName, map.stateName);
    return { message: 'Offline map updated successfully', data: this.serializeMap(map) };
  }

  async updateStatus(id: string, status: 'ACTIVE' | 'INACTIVE') {
    return this.update(id, { status });
  }

  async softDelete(id: string) {
    const current = await this.getActiveRecord(id);
    const map = await this.prisma.offlineMap.update({ where: { id }, data: { deletedAt: new Date() } });
    await this.clearMapCache(current.countryName, current.stateName);
    return { message: 'Offline map deleted successfully', data: this.serializeMap(map) };
  }

  async hardDelete(id: string) {
    const map = await this.prisma.offlineMap.findUnique({ where: { id } });
    if (!map) throw new NotFoundException('Offline map not found');
    await this.prisma.offlineMap.delete({ where: { id } });
    await this.clearMapCache(map.countryName, map.stateName);
    if (map.s3Key) await this.cleanupQueue.enqueueDelete(map.s3Key);
    return { message: 'Offline map hard deleted successfully', data: null };
  }

  private async createReplacementHistory(id: string, s3Key: string, uploadId: string) {
    const map = await this.getActiveRecord(id);
    await this.prisma.mapUploadHistory.create({
      data: { offlineMapId: id, oldS3Key: map.s3Key, newS3Key: s3Key, uploadId, status: 'UPLOADING' },
    });
    return map;
  }

  private validateZip(dto: InitiateUploadDto) {
    const max = Number(process.env.MAX_UPLOAD_SIZE_BYTES || 5368709120);
    if (!dto.fileName.toLowerCase().endsWith('.zip') || !ZIP_MIME_TYPES.has(dto.mimeType)) {
      throw new BadRequestException('Only ZIP files are allowed');
    }
    if (dto.fileSize <= 0 || dto.fileSize > max) throw new BadRequestException('Invalid file size');
  }

  private async getActiveRecord(id: string) {
    const map = await this.prisma.offlineMap.findFirst({ where: { id, deletedAt: null } });
    if (!map) throw new NotFoundException('Offline map not found');
    return map;
  }

  private async clearMapCache(country: string, state: string) {
    await this.redis.del(CacheKeys.mapUrl(country, state), CacheKeys.countries, CacheKeys.states(country));
  }

  private parseS3MapKey(key: string) {
    const parts = key.split('/').filter(Boolean);
    if (parts.length < 3) return null;
    const fileName = this.safeDecode(parts[parts.length - 1]);
    if (!fileName.toLowerCase().endsWith('.zip')) return null;
    return {
      countryName: this.humanizePathPart(parts[parts.length - 3]),
      stateName: this.humanizePathPart(parts[parts.length - 2]),
      fileName,
    };
  }

  private humanizePathPart(value: string) {
    const decoded = this.safeDecode(value).replace(/[-_]+/g, ' ').trim();
    return decoded.replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
  }

  private safeDecode(value: string) {
    try {
      return decodeURIComponent(value);
    } catch {
      return value;
    }
  }

  private serializeMap(map: unknown) {
    const serialized = JSON.parse(
      JSON.stringify(map, (_key, value) => (typeof value === 'bigint' ? Number(value) : value)),
    ) as unknown;
    if (serialized && typeof serialized === 'object' && 's3Key' in serialized && serialized.s3Key) {
      return { ...serialized, s3Url: this.s3.getPublicUrl(String(serialized.s3Key)) };
    }
    return serialized;
  }
}
