import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type {
  RateItemCreateInput,
  RateItemGalleryInput,
  RateItemUpdateInput,
  RateHistoryQuery,
  RateListQuery,
} from '@ali-ismail/contracts';
import { Prisma, RateApplicability, RateCategory, RateChangeField } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NumberingService } from '../numbering/numbering.service';
import { AuditService } from '../audit/audit.service';
import { toDecimalString } from '../common/money';

const IMAGE_MIME = /^image\//i;

function csvCell(v: unknown): string {
  const s = v == null ? '' : String(v);
  if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
  return s;
}

function toCsv(headers: string[], rows: (unknown[])[]): string {
  const lines = [headers.map(csvCell).join(',')];
  for (const row of rows) lines.push(row.map(csvCell).join(','));
  return lines.join('\r\n') + '\r\n';
}

@Injectable()
export class RatesService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly numbering: NumberingService,
    private readonly audit: AuditService,
  ) {}

  private detailInclude = {
    workerRates: { include: { workerType: true } },
    galleryImages: { orderBy: { sortOrder: 'asc' } },
  } satisfies Prisma.RateItemInclude;

  async list(organizationId: string, query: RateListQuery) {
    const search = query.search ?? query.q;
    const where: Prisma.RateItemWhereInput = {
      organizationId,
      ...(query.includeArchived ? {} : { archivedAt: null }),
      ...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
      ...(query.category ? { category: query.category as RateCategory } : {}),
      ...(search
        ? { OR: [{ name: { contains: search } }, { code: { contains: search } }] }
        : {}),
    };
    const [total, items] = await Promise.all([
      this.prisma.rateItem.count({ where }),
      this.prisma.rateItem.findMany({
        where,
        include: this.detailInclude,
        orderBy: { name: 'asc' },
        skip: (query.page - 1) * query.pageSize,
        take: query.pageSize,
      }),
    ]);
    return {
      items,
      page: query.page,
      pageSize: query.pageSize,
      total,
      totalPages: Math.max(1, Math.ceil(total / query.pageSize)),
    };
  }

  async get(organizationId: string, id: string) {
    const item = await this.prisma.rateItem.findFirst({
      where: { id, organizationId },
      include: this.detailInclude,
    });
    if (!item) throw new NotFoundException('Rate item not found');
    return {
      ...item,
      galleryImageIds: item.galleryImages.map((g) => g.mediaId),
    };
  }

  async create(organizationId: string, dto: RateItemCreateInput, actorUserId: string) {
    const settings = await this.prisma.organizationSettings.findUnique({ where: { organizationId } });
    const created = await this.prisma.$transaction(async (tx) => {
      const code =
        dto.code?.trim() ||
        (await this.numbering.nextNumber(organizationId, 'RTE', {
          timeZone: settings?.timezone ?? 'Asia/Karachi',
          tx,
        }));

      const item = await tx.rateItem.create({
        data: {
          organizationId,
          code,
          name: dto.name.trim(),
          category: dto.category as RateCategory,
          applicability: dto.applicability as RateApplicability,
          customerPrice: toDecimalString(dto.customerPrice),
          notes: dto.notes,
          description: dto.description ?? null,
          designCategory: dto.designCategory ?? null,
          effectiveFrom: dto.effectiveFrom ? new Date(dto.effectiveFrom) : null,
          isActive: dto.isActive ?? true,
          primaryImageId: dto.primaryImageId ?? null,
        },
      });

      if (dto.workerRates?.length) {
        for (const wr of dto.workerRates) {
          const wt = await tx.workerType.findFirst({
            where: { organizationId, code: wr.workerTypeCode, isActive: true },
          });
          if (!wt) continue;
          await tx.rateItemWorkerRate.create({
            data: {
              organizationId,
              rateItemId: item.id,
              workerTypeId: wt.id,
              amount: toDecimalString(wr.amount),
            },
          });
        }
      }

      return tx.rateItem.findUniqueOrThrow({
        where: { id: item.id },
        include: this.detailInclude,
      });
    });

    await this.audit.write({
      organizationId,
      actorUserId,
      action: 'rate.create',
      entityType: 'rate_item',
      entityId: created.id,
      after: created,
    });
    return created;
  }

  async update(organizationId: string, id: string, dto: RateItemUpdateInput, actorUserId: string) {
    const existing = await this.prisma.rateItem.findFirst({
      where: { id, organizationId },
      include: { workerRates: true },
    });
    if (!existing) throw new NotFoundException('Rate item not found');

    const effectiveFromForHistory =
      dto.effectiveFrom !== undefined && dto.effectiveFrom
        ? new Date(dto.effectiveFrom)
        : existing.effectiveFrom;
    const reason = dto.changeReason ?? null;

    const updated = await this.prisma.$transaction(async (tx) => {
      const historyRows: Prisma.RateChangeHistoryCreateManyInput[] = [];

      // ----- customerPrice change → CUSTOMER_PRICE history -----
      let newCustomerPrice: string | undefined;
      if (dto.customerPrice != null) {
        newCustomerPrice = toDecimalString(dto.customerPrice);
        if (!existing.customerPrice.equals(new Prisma.Decimal(newCustomerPrice))) {
          historyRows.push({
            organizationId,
            rateItemId: id,
            field: RateChangeField.CUSTOMER_PRICE,
            oldValue: existing.customerPrice,
            newValue: new Prisma.Decimal(newCustomerPrice),
            effectiveFrom: effectiveFromForHistory,
            reason,
            changedById: actorUserId,
          });
        }
      }

      // ----- isActive toggle → STATUS history (old/new as 1/0) -----
      if (dto.isActive !== undefined && dto.isActive !== existing.isActive) {
        historyRows.push({
          organizationId,
          rateItemId: id,
          field: RateChangeField.STATUS,
          oldValue: new Prisma.Decimal(existing.isActive ? 1 : 0),
          newValue: new Prisma.Decimal(dto.isActive ? 1 : 0),
          effectiveFrom: effectiveFromForHistory,
          reason,
          changedById: actorUserId,
        });
      }

      await tx.rateItem.update({
        where: { id },
        data: {
          name: dto.name?.trim(),
          category: dto.category as RateCategory | undefined,
          applicability: dto.applicability as RateApplicability | undefined,
          customerPrice: newCustomerPrice != null ? newCustomerPrice : undefined,
          notes: dto.notes,
          code: dto.code?.trim(),
          ...(dto.description !== undefined ? { description: dto.description ?? null } : {}),
          ...(dto.designCategory !== undefined ? { designCategory: dto.designCategory ?? null } : {}),
          ...(dto.effectiveFrom !== undefined
            ? { effectiveFrom: dto.effectiveFrom ? new Date(dto.effectiveFrom) : null }
            : {}),
          ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
          ...(dto.primaryImageId !== undefined ? { primaryImageId: dto.primaryImageId ?? null } : {}),
        },
      });

      // ----- worker rates diff → WORKER_RATE history (added/changed/removed) -----
      if (dto.workerRates) {
        // Resolve desired codes → workerType ids and amounts.
        const desired = new Map<string, Prisma.Decimal>(); // workerTypeId -> amount
        for (const wr of dto.workerRates) {
          const wt = await tx.workerType.findFirst({
            where: { organizationId, code: wr.workerTypeCode, isActive: true },
          });
          if (!wt) continue;
          desired.set(wt.id, new Prisma.Decimal(toDecimalString(wr.amount)));
        }
        const prev = new Map<string, Prisma.Decimal>(
          existing.workerRates.map((w) => [w.workerTypeId, w.amount]),
        );

        for (const [workerTypeId, amount] of desired) {
          const before = prev.get(workerTypeId);
          if (before === undefined || !before.equals(amount)) {
            historyRows.push({
              organizationId,
              rateItemId: id,
              workerTypeId,
              field: RateChangeField.WORKER_RATE,
              oldValue: before ?? null,
              newValue: amount,
              effectiveFrom: effectiveFromForHistory,
              reason,
              changedById: actorUserId,
            });
          }
        }
        for (const [workerTypeId, before] of prev) {
          if (!desired.has(workerTypeId)) {
            historyRows.push({
              organizationId,
              rateItemId: id,
              workerTypeId,
              field: RateChangeField.WORKER_RATE,
              oldValue: before,
              newValue: null, // removal
              effectiveFrom: effectiveFromForHistory,
              reason,
              changedById: actorUserId,
            });
          }
        }

        await tx.rateItemWorkerRate.deleteMany({ where: { rateItemId: id } });
        for (const [workerTypeId, amount] of desired) {
          await tx.rateItemWorkerRate.create({
            data: { organizationId, rateItemId: id, workerTypeId, amount },
          });
        }
      }

      if (historyRows.length) {
        await tx.rateChangeHistory.createMany({ data: historyRows });
      }

      return tx.rateItem.findUniqueOrThrow({ where: { id }, include: this.detailInclude });
    });

    await this.audit.write({
      organizationId,
      actorUserId,
      action: 'rate.update',
      entityType: 'rate_item',
      entityId: id,
      before: existing,
      after: updated,
      reason,
    });
    return updated;
  }

  /** Phase 6.2: replace the ordered gallery image set. Each mediaId must be an
   *  org-owned, non-archived image MediaAsset. */
  async replaceGallery(
    organizationId: string,
    id: string,
    dto: RateItemGalleryInput,
    actorUserId: string,
  ) {
    const item = await this.prisma.rateItem.findFirst({ where: { id, organizationId } });
    if (!item) throw new NotFoundException('Rate item not found');

    // Defect fix: reject duplicate mediaIds deterministically with 400 before
    // any write, so the DB unique constraint is never violated unpredictably.
    const ids = [...new Set(dto.imageIds)];
    if (ids.length !== dto.imageIds.length) {
      throw new BadRequestException('Gallery imageIds contains duplicate entries');
    }
    if (ids.length) {
      const assets = await this.prisma.mediaAsset.findMany({
        where: { id: { in: ids }, organizationId, archivedAt: null },
      });
      const byId = new Map(assets.map((a) => [a.id, a]));
      for (const mediaId of ids) {
        const a = byId.get(mediaId);
        if (!a) throw new BadRequestException(`Media not found or not owned: ${mediaId}`);
        if (!a.mimeType || !IMAGE_MIME.test(a.mimeType)) {
          throw new BadRequestException(`Media is not an image: ${mediaId}`);
        }
      }
    }

    await this.prisma.$transaction(async (tx) => {
      await tx.rateItemImage.deleteMany({ where: { rateItemId: id } });
      if (ids.length) {
        await tx.rateItemImage.createMany({
          data: ids.map((mediaId, i) => ({
            organizationId,
            rateItemId: id,
            mediaId,
            sortOrder: i,
          })),
        });
      }
    });

    await this.audit.write({
      organizationId,
      actorUserId,
      action: 'rate.gallery.update',
      entityType: 'rate_item',
      entityId: id,
      after: { imageIds: dto.imageIds },
    });
    return this.get(organizationId, id);
  }

  async historyForItem(organizationId: string, id: string) {
    const item = await this.prisma.rateItem.findFirst({ where: { id, organizationId } });
    if (!item) throw new NotFoundException('Rate item not found');
    return this.historyList(organizationId, { page: 1, pageSize: 100, rateItemId: id });
  }

  async historyList(organizationId: string, query: RateHistoryQuery) {
    const where: Prisma.RateChangeHistoryWhereInput = {
      organizationId,
      ...(query.rateItemId ? { rateItemId: query.rateItemId } : {}),
      ...(query.field ? { field: query.field as RateChangeField } : {}),
    };
    const [total, rows] = await Promise.all([
      this.prisma.rateChangeHistory.count({ where }),
      this.prisma.rateChangeHistory.findMany({
        where,
        include: { rateItem: { select: { code: true, name: true } } },
        orderBy: { createdAt: 'desc' },
        skip: (query.page - 1) * query.pageSize,
        take: query.pageSize,
      }),
    ]);

    // Enrich with worker type name + changedBy name (batched).
    const workerTypeIds = [...new Set(rows.map((r) => r.workerTypeId).filter(Boolean) as string[])];
    const changedByIds = [...new Set(rows.map((r) => r.changedById).filter(Boolean) as string[])];
    const [workerTypes, users] = await Promise.all([
      workerTypeIds.length
        ? this.prisma.workerType.findMany({
            where: { id: { in: workerTypeIds } },
            select: { id: true, name: true },
          })
        : Promise.resolve([]),
      changedByIds.length
        ? this.prisma.user.findMany({
            where: { id: { in: changedByIds } },
            select: { id: true, fullName: true },
          })
        : Promise.resolve([]),
    ]);
    const wtById = new Map(workerTypes.map((w) => [w.id, w.name]));
    const userById = new Map(users.map((u) => [u.id, u.fullName]));

    const items = rows.map((r) => ({
      ...r,
      rateItemCode: r.rateItem?.code ?? null,
      rateItemName: r.rateItem?.name ?? null,
      workerTypeName: r.workerTypeId ? wtById.get(r.workerTypeId) ?? null : null,
      changedByName: r.changedById ? userById.get(r.changedById) ?? null : null,
    }));

    return {
      items,
      page: query.page,
      pageSize: query.pageSize,
      total,
      totalPages: Math.max(1, Math.ceil(total / query.pageSize)),
    };
  }

  // ----------------------------- CSV exports -----------------------------

  private fmtDate(d: Date | null): string {
    return d ? d.toISOString().slice(0, 10) : '';
  }

  /** GARMENT + ADDON customer rates. */
  async exportCustomerRatesCsv(organizationId: string): Promise<string> {
    const items = await this.prisma.rateItem.findMany({
      where: {
        organizationId,
        archivedAt: null,
        category: { in: [RateCategory.GARMENT, RateCategory.ADDON] },
      },
      orderBy: [{ category: 'asc' }, { name: 'asc' }],
    });
    return toCsv(
      ['code', 'name', 'category', 'applicability', 'customerPrice', 'effectiveFrom', 'isActive'],
      items.map((i) => [
        i.code,
        i.name,
        i.category,
        i.applicability,
        i.customerPrice.toFixed(2),
        this.fmtDate(i.effectiveFrom),
        i.isActive ? '1' : '0',
      ]),
    );
  }

  /** One row per rateItem × workerType. */
  async exportWorkerRatesCsv(organizationId: string): Promise<string> {
    const rates = await this.prisma.rateItemWorkerRate.findMany({
      where: { organizationId, rateItem: { archivedAt: null } },
      include: {
        rateItem: { select: { code: true, name: true } },
        workerType: { select: { name: true } },
      },
      orderBy: [{ rateItem: { name: 'asc' } }, { workerType: { name: 'asc' } }],
    });
    return toCsv(
      ['rateItemCode', 'rateItemName', 'workerType', 'amount'],
      rates.map((r) => [r.rateItem.code, r.rateItem.name, r.workerType.name, r.amount.toFixed(2)]),
    );
  }

  /** DESIGN items. */
  async exportDesignRatesCsv(organizationId: string): Promise<string> {
    const items = await this.prisma.rateItem.findMany({
      where: { organizationId, archivedAt: null, category: RateCategory.DESIGN },
      orderBy: { name: 'asc' },
    });
    return toCsv(
      ['code', 'name', 'designCategory', 'applicability', 'customerPrice', 'effectiveFrom', 'isActive'],
      items.map((i) => [
        i.code,
        i.name,
        i.designCategory ?? '',
        i.applicability,
        i.customerPrice.toFixed(2),
        this.fmtDate(i.effectiveFrom),
        i.isActive ? '1' : '0',
      ]),
    );
  }
}
