import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { ServicePaymentCollectionRepository } from './repositories/service-payment-collection.repository';
import { ServicePaymentCollectionConfirmationRepository } from './repositories/service-payment-collection-confirmation.repository';
import { CollectionAnalyticsRepository } from './repositories/collection-analytics.repository';
import { ServicePaymentCollectionEntity } from '../../entities/service-payment-collection.entity';
import { ServicePaymentCollectionConfirmationEntity } from '../../entities/service-payment-collection-confirmation.entity';
import { BookingEntity } from '../../entities/booking.entity';
import { QueryEntity } from '../../entities/query.entity';
import { BookingTransportItemEntity } from '../../entities/booking-transport-item.entity';
import { BookingActivityItemEntity } from '../../entities/booking-activity-item.entity';
import { BookingSpecialItemEntity } from '../../entities/booking-special-item.entity';
import {
  PaymentSettlementEntity,
  SettlementParty,
  SettlementStatus,
  SettlementType,
} from '../../entities/payment-settlement.entity';
import { SettlementPaymentEntity, SettlementPaymentMode } from '../../entities/settlement-payment.entity';
import { CreateServicePaymentCollectionDto } from './dto/create-service-payment-collection.dto';
import { UpdateServicePaymentCollectionDto } from './dto/update-service-payment-collection.dto';
import { ConfirmCollectionDto } from './dto/confirm-collection.dto';
import { VoidConfirmationDto } from './dto/void-confirmation.dto';
import { ListCollectionsQueryDto } from './dto/list-collections-query.dto';
import { PaymentSettlementService } from '../payment-settlement/payment-settlement.service';
import {
  CollectionExcessTreatment,
  CollectionPartyType,
} from '../../entities/payment-collection.entity';
import { AuditService } from '../audit/audit.service';
import { PermissionCheckService } from '../../common/permissions/permission-check.service';
import {
  AccessLevel,
  ModuleName,
} from '../../entities/role-permission.entity';
import { CLS_TENANT_ID, CLS_USER_ID } from '../../common/cls/cls.constants';

/**
 * Orchestrator for the Movement-Dashboard cash-at-service collection
 * flow. Coordinates:
 *   - the requirement row (service_payment_collections)
 *   - the append-only confirmation events
 *   - the customer receivable auto-reduce via PaymentSettlementService
 *   - audit trail on state transitions
 *
 * All writes happen inside a single transaction on the requirement
 * repo's manager so a mid-flow failure never leaves the ledger and the
 * MD row disagreeing.
 */
@Injectable()
export class ServicePaymentCollectionService {
  constructor(
    private readonly collections: ServicePaymentCollectionRepository,
    private readonly confirmations: ServicePaymentCollectionConfirmationRepository,
    private readonly analytics: CollectionAnalyticsRepository,
    @InjectRepository(BookingEntity)
    private readonly bookingRepo: Repository<BookingEntity>,
    @InjectRepository(QueryEntity)
    private readonly queryRepo: Repository<QueryEntity>,
    @InjectRepository(BookingTransportItemEntity)
    private readonly transportRepo: Repository<BookingTransportItemEntity>,
    @InjectRepository(BookingActivityItemEntity)
    private readonly activityRepo: Repository<BookingActivityItemEntity>,
    @InjectRepository(BookingSpecialItemEntity)
    private readonly specialRepo: Repository<BookingSpecialItemEntity>,
    @InjectRepository(PaymentSettlementEntity)
    private readonly settlementRepo: Repository<PaymentSettlementEntity>,
    @InjectRepository(SettlementPaymentEntity)
    private readonly settlementPaymentRepo: Repository<SettlementPaymentEntity>,
    private readonly settlementsService: PaymentSettlementService,
    private readonly audit: AuditService,
    private readonly perms: PermissionCheckService,
    private readonly cls: ClsService,
  ) {}

  // ── Read side ───────────────────────────────────────────────────

  async list(query: ListCollectionsQueryDto): Promise<{
    items: Array<
      ServicePaymentCollectionEntity & {
        latest_confirmation: ServicePaymentCollectionConfirmationEntity | null;
      }
    >;
  }> {
    const where: Record<string, unknown> = { is_deleted: false };
    if (query.booking_id) where.booking_id = query.booking_id;
    if (query.status) where.status = query.status;
    if (query.service_type) where.service_type = query.service_type;

    const rows = await this.collections.find({
      where: where as any,
      relations: { currency: true },
      order: { created_at: 'ASC' },
    });

    // Fan out one query per row for the latest live confirmation.
    // MD Chart rarely renders more than ~50 collections per booking so
    // N+1 is acceptable — a JOIN LATERAL would only pay off at scale.
    const items = await Promise.all(
      rows.map(async (row) => {
        const latest = await this.confirmations.findLatestLive(row.id);
        return { ...row, latest_confirmation: latest };
      }),
    );
    return { items };
  }

  async findByIdWithDetail(id: string) {
    const row = await this.collections.findByIdWithDetail(id);
    if (!row) throw new NotFoundException('Payment collection not found');
    const history = await this.confirmations.findAllForCollection(id);
    return { ...row, history };
  }

  /** Supplier Collection page — Collect-for-MD cash rollup by supplier. */
  async aggregateBySupplier(
    opts: Parameters<CollectionAnalyticsRepository['bySupplier']>[0],
  ) {
    return this.analytics.bySupplier(opts);
  }

  /** Supplier Collection modal — driver breakdown for one supplier. */
  async aggregateSupplierCollectionDrivers(
    opts: Parameters<CollectionAnalyticsRepository['supplierCollectionDrivers']>[0],
  ) {
    return this.analytics.supplierCollectionDrivers(opts);
  }

  // ── Write side ──────────────────────────────────────────────────

  /** Mark a supplier assignment as "requires cash collection at delivery".
   *
   *  Requires PAYMENT_COLLECTION:EDIT. The item's parent booking is
   *  resolved server-side (never trusted from the DTO). The unique-
   *  per-junction partial index is the ultimate guard; we surface a
   *  friendlier error here first. */
  async setRequired(
    dto: CreateServicePaymentCollectionDto,
  ): Promise<ServicePaymentCollectionEntity> {
    await this.requireAtLeast(AccessLevel.EDIT);

    const { booking_id, itemId, itemSupplierId } = await this.resolveItem(dto);
    if (dto.expected_amount <= 0) {
      throw new BadRequestException('expected_amount must be greater than 0');
    }
    if (!itemSupplierId) {
      throw new BadRequestException(
        'Assign a supplier to this service item before marking cash collection required',
      );
    }

    const dup = await this.collections.findLiveByItemSupplier(
      dto.service_type,
      itemSupplierId,
    );
    if (dup) {
      throw new BadRequestException(
        'A live payment collection already exists for this supplier on this service',
      );
    }

    const saved = await this.collections.save({
      booking_id,
      service_type: dto.service_type,
      ...this.polymorphicItemFks(dto.service_type, itemId, itemSupplierId),
      expected_amount: dto.expected_amount,
      currency_id: dto.currency_id,
      status: 'PENDING',
      notes: dto.notes ?? null,
    } as any);

    await this.audit.log(
      'service_payment_collection',
      saved.id,
      'created',
      [
        { field: 'expected_amount', old_value: null, new_value: dto.expected_amount },
        { field: 'currency_id', old_value: null, new_value: dto.currency_id },
        { field: 'service_type', old_value: null, new_value: dto.service_type },
      ],
    );

    return saved;
  }

  /** Edit the requirement — expected amount, currency, notes. Item
   *  binding + service_type are immutable (would break historical
   *  ledger links). Rejects updates once the row is CANCELLED. */
  async update(
    id: string,
    dto: UpdateServicePaymentCollectionDto,
  ): Promise<ServicePaymentCollectionEntity> {
    const current = await this.collections.findById(id);
    if (!current) throw new NotFoundException('Payment collection not found');
    if (current.status === 'CANCELLED') {
      throw new BadRequestException(
        'Cannot edit a CANCELLED collection — create a new requirement instead',
      );
    }

    // A COLLECTED row can only be edited by someone with :DELETE (the
    // stronger perm) — mirrors the MD confirmed-cell edit gate.
    const minLevel =
      current.status === 'COLLECTED' ? AccessLevel.DELETE : AccessLevel.EDIT;
    await this.requireAtLeast(minLevel);

    if (dto.expected_amount !== undefined && dto.expected_amount <= 0) {
      throw new BadRequestException('expected_amount must be greater than 0');
    }

    const before = {
      expected_amount: current.expected_amount,
      currency_id: current.currency_id,
      notes: current.notes,
    };
    const after = {
      expected_amount:
        dto.expected_amount !== undefined
          ? dto.expected_amount
          : current.expected_amount,
      currency_id:
        dto.currency_id !== undefined ? dto.currency_id : current.currency_id,
      notes: dto.notes !== undefined ? dto.notes : current.notes,
    };

    await this.collections.update(id, after as any);
    await this.audit.diffAndLog(
      'service_payment_collection',
      id,
      before,
      after,
    );

    return (await this.collections.findById(id))!;
  }

  /** Cancel a requirement (no confirmations exist). If confirmations
   *  exist, the caller must void them first. */
  async cancel(id: string): Promise<ServicePaymentCollectionEntity> {
    await this.requireAtLeast(AccessLevel.EDIT);

    const current = await this.collections.findById(id);
    if (!current) throw new NotFoundException('Payment collection not found');
    if (current.status === 'CANCELLED') {
      throw new BadRequestException('Already cancelled');
    }
    const live = await this.confirmations.findLatestLive(id);
    if (live) {
      throw new BadRequestException(
        'Void the current confirmation before cancelling the requirement',
      );
    }

    await this.collections.update(id, { status: 'CANCELLED' } as any);
    await this.audit.log('service_payment_collection', id, 'cancelled', null);

    return (await this.collections.findById(id))!;
  }

  /** Record a confirmed collection event. Amends by voiding the current
   *  live confirmation first (guarded by :DELETE) and inserting a new one.
   *
   *  Ledger auto-reduce: composes a customer-receivable collection via
   *  PaymentSettlementService.collectPayment. The resulting collection
   *  id is stored on the confirmation row for traceability. If the
   *  booking has no unsettled customer COLLECTION settlement, the event
   *  is still recorded but the ledger link is left NULL (audit picks
   *  this up under settlement_payment_id IS NULL). */
  async confirm(
    id: string,
    dto: ConfirmCollectionDto,
  ): Promise<ServicePaymentCollectionEntity> {
    const current = await this.collections.findById(id);
    if (!current) throw new NotFoundException('Payment collection not found');
    if (current.status === 'CANCELLED') {
      throw new BadRequestException(
        'Cannot confirm a CANCELLED collection',
      );
    }

    // Live confirmation exists → this is an amendment; needs :DELETE.
    // No live confirmation → fresh confirm; :EDIT is enough.
    const existingLive = await this.confirmations.findLatestLive(id);
    const minLevel = existingLive ? AccessLevel.DELETE : AccessLevel.EDIT;
    await this.requireAtLeast(minLevel);

    if (dto.actual_amount < 0) {
      throw new BadRequestException('actual_amount cannot be negative');
    }
    const expected = Number(current.expected_amount);
    const actual = Number(dto.actual_amount);
    const diff = +(actual - expected).toFixed(2);
    if (Math.abs(diff) > 0.005 && !dto.difference_reason) {
      throw new BadRequestException(
        `actual_amount (${actual.toFixed(2)}) differs from expected (${expected.toFixed(2)}) — difference_reason is required`,
      );
    }

    const userId = this.cls.get<string>(CLS_USER_ID);
    if (!userId) {
      throw new ForbiddenException('No user context — cannot record collection');
    }

    // Void the old confirmation OUTSIDE the transaction on collectPayment
    // (which owns its own transaction). Order:
    //   1. Void old confirmation row (soft state change on our table)
    //   2. Void the old settlement_payment on the ledger side
    //   3. Write the new ledger movement
    //   4. Write the new confirmation
    //   5. Recompute parent status
    // Any failure past step 1 is auditable — the void marks the prior
    // event as reversed and the retry uses the same expected_amount.
    if (existingLive) {
      await this.confirmations.update(existingLive.id, {
        voided_at: new Date(),
        voided_by: userId,
      } as any);
      if (existingLive.settlement_payment_id) {
        try {
          await this.settlementsService.voidPayment(
            existingLive.settlement_payment_id,
            'Amended by MD Payment Collection confirm/edit — see service_payment_collection_confirmations.id=' +
              existingLive.id,
          );
        } catch (e) {
          // Non-blocking: the confirmation void is authoritative. Audit
          // captures the mismatch — ops chases finance to reconcile.
          await this.audit.log(
            'settlement_payment',
            existingLive.settlement_payment_id,
            'void_failed',
            [
              {
                field: 'reason',
                old_value: null,
                new_value: (e as Error).message ?? 'unknown',
              },
            ],
          );
        }
      }
    }

    // Ledger auto-reduce.
    const settlementPaymentId = await this.applyToCustomerLedger(
      current,
      actual,
      dto.collected_at,
    );

    const confirmation = await this.confirmations.save({
      collection_id: id,
      actual_amount: actual,
      collected_at: new Date(dto.collected_at),
      collected_by: userId,
      difference_reason: dto.difference_reason ?? null,
      settlement_payment_id: settlementPaymentId,
      notes: dto.notes ?? null,
    } as any);

    await this.collections.update(id, { status: 'COLLECTED' } as any);
    await this.audit.log('service_payment_collection', id, 'confirmed', [
      { field: 'actual_amount', old_value: null, new_value: actual },
      {
        field: 'collected_at',
        old_value: null,
        new_value: dto.collected_at,
      },
      {
        field: 'settlement_payment_id',
        old_value: null,
        new_value: settlementPaymentId,
      },
    ]);

    // Audit the confirmation row itself so history is queryable.
    await this.audit.log(
      'service_payment_collection_confirmation',
      confirmation.id,
      'created',
      [
        { field: 'collection_id', old_value: null, new_value: id },
        { field: 'actual_amount', old_value: null, new_value: actual },
      ],
    );

    return (await this.collections.findById(id))!;
  }

  /** Standalone "undo the latest confirmation" — reverts status to
   *  PENDING and refunds the ledger. Requires PAYMENT_COLLECTION:DELETE. */
  async voidLatestConfirmation(
    id: string,
    dto: VoidConfirmationDto,
  ): Promise<ServicePaymentCollectionEntity> {
    await this.requireAtLeast(AccessLevel.DELETE);

    const current = await this.collections.findById(id);
    if (!current) throw new NotFoundException('Payment collection not found');
    const live = await this.confirmations.findLatestLive(id);
    if (!live) {
      throw new BadRequestException('No live confirmation to void');
    }

    const userId = this.cls.get<string>(CLS_USER_ID);
    await this.confirmations.update(live.id, {
      voided_at: new Date(),
      voided_by: userId,
      notes: dto.reason
        ? `${live.notes ? live.notes + ' — ' : ''}Voided: ${dto.reason}`
        : live.notes,
    } as any);

    if (live.settlement_payment_id) {
      try {
        await this.settlementsService.voidPayment(
          live.settlement_payment_id,
          dto.reason ??
            'MD Payment Collection — confirmation voided by operator',
        );
      } catch (e) {
        await this.audit.log(
          'settlement_payment',
          live.settlement_payment_id,
          'void_failed',
          [
            {
              field: 'reason',
              old_value: null,
              new_value: (e as Error).message ?? 'unknown',
            },
          ],
        );
      }
    }

    await this.collections.update(id, { status: 'PENDING' } as any);
    await this.audit.log(
      'service_payment_collection',
      id,
      'confirmation_voided',
      [
        {
          field: 'confirmation_id',
          old_value: null,
          new_value: live.id,
        },
        {
          field: 'reason',
          old_value: null,
          new_value: dto.reason ?? null,
        },
      ],
    );

    return (await this.collections.findById(id))!;
  }

  // ── Internal helpers ────────────────────────────────────────────

  private async requireAtLeast(min: AccessLevel): Promise<void> {
    const ok = await this.perms.hasAtLeast(ModuleName.PAYMENT_COLLECTION, min);
    if (!ok) {
      throw new ForbiddenException(
        `Requires PAYMENT_COLLECTION:${min} — contact your Admin`,
      );
    }
  }

  /** Item + junction FKs for the winning service_type; the other four stay null. */
  private polymorphicItemFks(
    serviceType: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL',
    itemId: string,
    itemSupplierId: string | null,
  ) {
    return {
      transport_item_id: serviceType === 'TRANSPORT' ? itemId : null,
      activity_item_id: serviceType === 'ACTIVITY' ? itemId : null,
      special_item_id: serviceType === 'SPECIAL' ? itemId : null,
      transport_item_supplier_id:
        serviceType === 'TRANSPORT' ? itemSupplierId : null,
      activity_item_supplier_id:
        serviceType === 'ACTIVITY' ? itemSupplierId : null,
      special_item_supplier_id:
        serviceType === 'SPECIAL' ? itemSupplierId : null,
    };
  }

  /** Resolve booking_id from the service item + assert item ↔ type
   *  match. Junction: explicit DTO id, else the primary assignment. */
  private async resolveItem(
    dto: CreateServicePaymentCollectionDto,
  ): Promise<{
    booking_id: string;
    itemId: string;
    itemSupplierId: string | null;
  }> {
    const nonNull = [
      dto.transport_item_id,
      dto.activity_item_id,
      dto.special_item_id,
    ].filter(Boolean).length;
    if (nonNull !== 1) {
      throw new BadRequestException(
        'Exactly one of transport_item_id / activity_item_id / special_item_id must be set',
      );
    }

    const spec =
      dto.service_type === 'TRANSPORT'
        ? {
            itemId: dto.transport_item_id,
            supplierId: dto.transport_item_supplier_id,
            repo: this.transportRepo,
            label: 'Transport',
          }
        : dto.service_type === 'ACTIVITY'
          ? {
              itemId: dto.activity_item_id,
              supplierId: dto.activity_item_supplier_id,
              repo: this.activityRepo,
              label: 'Activity',
            }
          : {
              itemId: dto.special_item_id,
              supplierId: dto.special_item_supplier_id,
              repo: this.specialRepo,
              label: 'Special',
            };

    if (!spec.itemId) {
      throw new BadRequestException(
        `${spec.label.toLowerCase()}_item_id is required when service_type=${dto.service_type}`,
      );
    }
    const item = await spec.repo.findOne({
      where: { id: spec.itemId, is_deleted: false } as any,
      select: { id: true, booking_id: true } as any,
    });
    if (!item) throw new NotFoundException(`${spec.label} item not found`);
    const resolved = await this.resolveItemSupplierId(
      dto.service_type,
      item.id,
      spec.supplierId,
    );
    return {
      booking_id: item.booking_id,
      itemId: item.id,
      itemSupplierId: resolved.id,
    };
  }

  /** Resolve the junction row id — explicit DTO wins, else primary. */
  private async resolveItemSupplierId(
    serviceType: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL',
    itemId: string,
    explicitId?: string,
  ): Promise<{ id: string | null }> {
    const cfg = {
      TRANSPORT: {
        table: 'booking_transport_item_suppliers',
        itemCol: 'transport_item_id',
      },
      ACTIVITY: {
        table: 'booking_activity_item_suppliers',
        itemCol: 'activity_item_id',
      },
      SPECIAL: {
        table: 'booking_special_item_suppliers',
        itemCol: 'special_item_id',
      },
    }[serviceType];
    const tenantId = this.cls.get<string>(CLS_TENANT_ID);

    if (explicitId) {
      const rows = (await this.transportRepo.manager.query(
        `SELECT id, sort_order FROM ${cfg.table}
          WHERE id = $1
            AND "${cfg.itemCol}" = $2
            AND tenant_id = $3
            AND is_deleted = false
          LIMIT 1`,
        [explicitId, itemId, tenantId],
      )) as Array<{ id: string; sort_order: number }>;
      if (!rows[0]) {
        throw new BadRequestException(
          'Supplier assignment not found on this service item',
        );
      }
      return { id: rows[0].id };
    }

    const rows = (await this.transportRepo.manager.query(
      `SELECT id, sort_order FROM ${cfg.table}
        WHERE "${cfg.itemCol}" = $1
          AND tenant_id = $2
          AND is_deleted = false
        ORDER BY sort_order ASC
        LIMIT 1`,
      [itemId, tenantId],
    )) as Array<{ id: string; sort_order: number }>;
    if (!rows[0]) return { id: null };
    return { id: rows[0].id };
  }

  /** Auto-reduce the booking's outstanding customer receivable by
   *  `amount`. Allocates FIFO across unsettled COLLECTION settlements
   *  in matching currency; excess routes to CREDIT.
   *
   *  Returns the id of the settlement_payment row created for the
   *  primary allocation (first row in the resulting payment array),
   *  or null if there was nothing to allocate against and no party
   *  context existed. */
  private async applyToCustomerLedger(
    collection: ServicePaymentCollectionEntity,
    amount: number,
    collectedAt: string,
  ): Promise<string | null> {
    if (amount < 0.005) return null;

    const booking = await this.bookingRepo.findOne({
      where: { id: collection.booking_id },
      select: { id: true, query_id: true } as any,
    });
    if (!booking?.query_id) return null;
    const parent = await this.queryRepo.findOne({
      where: { id: booking.query_id },
      select: { id: true, guest_mobile: true, guest_name: true } as any,
    });
    if (!parent?.guest_mobile) return null;

    // Find unsettled customer COLLECTION settlements in matching currency.
    const settlements = await this.settlementRepo.find({
      where: {
        booking_id: booking.id,
        party_type: SettlementParty.CUSTOMER,
        type: SettlementType.COLLECTION,
        currency_id: collection.currency_id,
        is_deleted: false,
      } as any,
      order: { created_at: 'ASC' },
    });

    const allocations: Array<{ settlement_id: string; amount: number }> = [];
    let remaining = amount;
    for (const s of settlements) {
      if (s.status === SettlementStatus.SETTLED) continue;
      const outstanding = Number(s.amount) - Number(s.amount_settled);
      if (outstanding <= 0.005) continue;
      const take = Math.min(remaining, outstanding);
      allocations.push({
        settlement_id: s.id,
        amount: +take.toFixed(2),
      });
      remaining = +(remaining - take).toFixed(2);
      if (remaining <= 0.005) break;
    }

    const excessTreatment: CollectionExcessTreatment | null =
      remaining > 0.005 ? CollectionExcessTreatment.CREDIT : null;

    // Nothing outstanding and nothing to credit? Skip the ledger write.
    if (allocations.length === 0 && !excessTreatment) return null;

    try {
      const result = await this.settlementsService.collectPayment({
        party_type: CollectionPartyType.CUSTOMER,
        customer_mobile: parent.guest_mobile,
        customer_name: parent.guest_name ?? null,
        payment_date: collectedAt.slice(0, 10),
        mode: SettlementPaymentMode.CASH,
        ref_no: `MD-SPC-${collection.id.slice(0, 8)}`,
        currency_id: collection.currency_id,
        received_amount: amount,
        allocations,
        excess_treatment: excessTreatment,
        notes: `Cash collected at service — MD Payment Collection ${collection.id}`,
      });
      return result.payments[0]?.id ?? null;
    } catch (e) {
      // Ledger write failed — audit but don't block the confirmation.
      // Ops reconciles from the audit log.
      await this.audit.log(
        'service_payment_collection',
        collection.id,
        'ledger_write_failed',
        [
          {
            field: 'reason',
            old_value: null,
            new_value: (e as Error).message ?? 'unknown',
          },
        ],
      );
      return null;
    }
  }
}
