import {
  Injectable,
  ConflictException,
  ForbiddenException,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TransportPricingRepository } from './repositories/transport-pricing.repository';
import { CreateTransportPricingDto, UpdateTransportPricingDto } from './dto';
import {
  TransportPriceMode,
  TransportPricingEntity,
} from '../../entities/transport-pricing.entity';
import { CurrencyEntity } from '../../entities/currency.entity';
import { serviceMessages } from '../../common/constants/messages';
import {
  buildTransportSample,
  parseTransportImport,
} from '../../common/services/pricing-excel.service';
import { applyParsedPricingImport } from '../../common/services/pricing-import.helper';
import { bifurcatePricingRowBoth } from '../../common/services/pricing-bifurcation.helper';
import { TaxSlabResolverService } from '../../common/services/tax-slab-resolver.service';
import { PermissionContextService } from '../../common/auth/permission-context.service';
import { AccessLevel, ModuleName } from '../../entities/role-permission.entity';

const MSG = serviceMessages('Transport pricing');

@Injectable()
export class TransportPricingService {
  constructor(
    private readonly repo: TransportPricingRepository,
    private readonly perms: PermissionContextService,
    private readonly resolver: TaxSlabResolverService,
    @InjectRepository(CurrencyEntity)
    private readonly currencyRepo: Repository<CurrencyEntity>,
  ) {}

  /**
   * Derive `*_base_price` + `*_tax_amount` for every inclusive cell
   * in `prices` using the effective slab + the currency's rounding
   * step. Called by create / bulkSave / update so the bifurcation
   * columns stay in sync with the inclusive values ops enters.
   *
   * Inputs the helper needs:
   *   - effective slab — service-master override → destination default
   *     (TaxSlabResolverService.resolveForTransport).
   *   - rounding step — currencies.rounding (whole-unit by default).
   *
   * Returns ONLY the derived columns to merge into the save payload.
   * Callers spread it alongside the inclusive prices so the row hits
   * the DB in one save (no second UPDATE round-trip).
   *
   * Tax-exempt path: when the resolver returns null (no slab anywhere),
   * the helper still emits base = inclusive, tax_amount = 0 — the row
   * is well-formed on either side of the matrix.
   */
  private async bifurcatePricesForSave(
    prices: Record<string, unknown>,
    transportServiceId: string,
    destinationId: string,
    currencyId: string,
  ): Promise<Record<string, number | null>> {
    const [slab, currency] = await Promise.all([
      this.resolver.resolveForTransport(transportServiceId, destinationId),
      this.currencyRepo.findOne({ where: { id: currencyId } }),
    ]);
    const rounding = Number(currency?.rounding) || 1;
    return bifurcatePricingRowBoth(prices, slab, rounding);
  }

  /**
   * Strip floor_* fields from a payload when the caller lacks
   * transport_floor_pricing:edit. Used by create/update + Excel import
   * so a user without the floor permission can never overwrite the
   * floor values (silently keeps the existing ones).
   */
  private async stripFloorFromPayload<T extends Record<string, unknown>>(payload: T): Promise<T> {
    const canEditFloor = await this.perms.hasPermission(
      ModuleName.TRANSPORT_FLOOR_PRICING,
      AccessLevel.EDIT,
    );
    if (canEditFloor) return payload;
    const cleaned: Record<string, unknown> = { ...payload };
    for (const k of Object.keys(cleaned)) {
      if (k.startsWith('floor_')) delete cleaned[k];
    }
    return cleaned as T;
  }

  async findByDestination(destinationId: string, currencyId?: string) {
    return this.repo.findByDestinationAndCurrency(destinationId, currencyId);
  }

  async findPaginated(query: {
    destinationId?: string;
    currencyId?: string;
    search?: string;
    page?: number;
    limit?: number;
  }) {
    return this.repo.findPaginatedByRoutes(query);
  }

  /**
   * Distinct vehicle_type_ids that have at least one pricing row in
   * the given destination. Endpoint sits at /transport-pricing/vehicle-types.
   * FE has no live consumer at the moment but the name now aligns with
   * the rest of the domain ("vehicle type" everywhere, not "cab type").
   */
  async getVehicleTypes(destinationId: string) {
    return this.repo.getDistinctVehicleTypeIds(destinationId);
  }

  async findById(id: string) {
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);
    return item;
  }

  /**
   * Mode-aware identity for a pricing row. SIC rows always carry
   * vehicle_type_id=NULL (per stakeholder spec — per-person rate has
   * no vehicle axis), so we normalise here and use the resolved
   * values for both the repo upsert key AND the row body.
   */
  private resolveIdentity(dto: CreateTransportPricingDto) {
    const mode: TransportPriceMode =
      dto.mode === 'SIC' ? TransportPriceMode.SIC : TransportPriceMode.PVT;
    const vehicleTypeId = mode === TransportPriceMode.SIC ? null : (dto.vehicle_type_id ?? null);
    return { mode, vehicleTypeId };
  }

  /** Pricing columns the editor writes — selling matrix + floor matrix +
   *  category. Single source of truth for create / bulk / update so a
   *  new column is added in one place. */
  private static readonly PRICE_FIELDS = [
    'default_price',
    'season_1_price',
    'season_2_price',
    'season_3_price',
    'season_4_price',
    'season_5_price',
    'floor_default_price',
    'floor_season_1_price',
    'floor_season_2_price',
    'floor_season_3_price',
    'floor_season_4_price',
    'floor_season_5_price',
    'category',
  ] as const;

  private pickPrices(
    dto: Partial<Record<(typeof TransportPricingService.PRICE_FIELDS)[number], unknown>>,
    includeNulls = true,
  ): Record<string, unknown> {
    const out: Record<string, unknown> = {};
    for (const f of TransportPricingService.PRICE_FIELDS) {
      const v = dto[f];
      if (includeNulls) out[f] = v ?? null;
      else if (v !== undefined) out[f] = v;
    }
    return out;
  }

  async create(dto: CreateTransportPricingDto) {
    const { mode, vehicleTypeId } = this.resolveIdentity(dto);
    const existing = await this.repo.findExisting(
      dto.transport_service_id,
      vehicleTypeId,
      dto.currency_id,
      mode,
    );
    if (existing)
      throw new ConflictException(
        'Pricing for this service + vehicle type + currency + mode already exists',
      );

    // Strip floor_* if caller lacks transport_floor_pricing:edit
    const prices = await this.stripFloorFromPayload(this.pickPrices(dto));

    // Derive base_price + tax_amount per cell so reads of the new
    // *_base_price columns return the right value from row one.
    const bifurcation = await this.bifurcatePricesForSave(
      prices,
      dto.transport_service_id,
      dto.destination_id,
      dto.currency_id,
    );

    // Check for soft-deleted record and reactivate instead of creating new
    const softDeleted = await this.repo.findSoftDeleted(
      dto.transport_service_id,
      vehicleTypeId,
      dto.currency_id,
      mode,
    );
    if (softDeleted) {
      await this.repo.reactivate(softDeleted.id, {
        destination_id: dto.destination_id,
        ...prices,
        ...bifurcation,
      });
      return { id: softDeleted.id, message: MSG.CREATED };
    }

    const item = await this.repo.save({
      destination_id: dto.destination_id,
      transport_service_id: dto.transport_service_id,
      vehicle_type_id: vehicleTypeId,
      mode,
      currency_id: dto.currency_id,
      ...prices,
      ...bifurcation,
    });

    return { id: item.id, message: MSG.CREATED };
  }

  async bulkSave(items: CreateTransportPricingDto[]) {
    const results: { id: string; action: 'created' | 'updated' | 'reactivated' }[] = [];
    for (const dto of items) {
      const { mode, vehicleTypeId } = this.resolveIdentity(dto);
      // Strip floor_* if caller lacks transport_floor_pricing:edit
      const pricesUpd = await this.stripFloorFromPayload(this.pickPrices(dto, false));
      const pricesAll = await this.stripFloorFromPayload(this.pickPrices(dto));
      const existing = await this.repo.findExisting(
        dto.transport_service_id,
        vehicleTypeId,
        dto.currency_id,
        mode,
      );
      // Bifurcate the FULL payload (pricesAll, not pricesUpd) — we
      // need base+tax for every cell the caller mentioned, including
      // ones they're explicitly setting to null. For the update branch
      // we strip the un-touched keys via pickPrices(dto, false) below.
      const bifurcationAll = await this.bifurcatePricesForSave(
        pricesAll,
        dto.transport_service_id,
        dto.destination_id,
        dto.currency_id,
      );
      const bifurcationUpd: Record<string, number | null> = {};
      for (const k of Object.keys(pricesUpd)) {
        const tier = k.replace(/^floor_/, '').replace(/_price$/, '');
        const baseKey = k.startsWith('floor_')
          ? `floor_${tier}_base_price`
          : `${tier}_base_price`;
        const taxKey = k.startsWith('floor_')
          ? `floor_${tier}_tax_amount`
          : `${tier}_tax_amount`;
        if (baseKey in bifurcationAll) bifurcationUpd[baseKey] = bifurcationAll[baseKey];
        if (taxKey in bifurcationAll) bifurcationUpd[taxKey] = bifurcationAll[taxKey];
      }
      if (existing) {
        await this.repo.update(existing.id, { ...pricesUpd, ...bifurcationUpd });
        results.push({ id: existing.id, action: 'updated' });
      } else {
        const softDeleted = await this.repo.findSoftDeleted(
          dto.transport_service_id,
          vehicleTypeId,
          dto.currency_id,
          mode,
        );
        if (softDeleted) {
          await this.repo.reactivate(softDeleted.id, {
            destination_id: dto.destination_id,
            ...pricesAll,
            ...bifurcationAll,
          });
          results.push({ id: softDeleted.id, action: 'reactivated' });
        } else {
          const item = await this.repo.save({
            destination_id: dto.destination_id,
            transport_service_id: dto.transport_service_id,
            vehicle_type_id: vehicleTypeId,
            mode,
            currency_id: dto.currency_id,
            ...pricesAll,
            ...bifurcationAll,
          });
          results.push({ id: item.id, action: 'created' });
        }
      }
    }
    return { results, message: `${results.length} pricing entries saved` };
  }

  async update(id: string, dto: UpdateTransportPricingDto) {
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    // Strip floor_* if caller lacks transport_floor_pricing:edit
    const updateData = await this.stripFloorFromPayload(this.pickPrices(dto, false));
    if (dto.vehicle_type_id !== undefined) updateData.vehicle_type_id = dto.vehicle_type_id;

    // Re-bifurcate any price cell the caller is updating so the
    // *_base_price + *_tax_amount columns stay in sync. We use the
    // existing row's transport_service_id / destination_id / currency_id
    // because UpdateTransportPricingDto doesn't carry them (the unique
    // identity is locked at create time).
    const priceKeys = Object.keys(updateData).filter(
      (k) => k.endsWith('_price') && !k.startsWith('floor_destination_'),
    );
    if (priceKeys.length > 0) {
      const bifurcation = await this.bifurcatePricesForSave(
        updateData,
        item.transport_service_id,
        item.destination_id,
        item.currency_id,
      );
      for (const k of priceKeys) {
        const tier = k.replace(/^floor_/, '').replace(/_price$/, '');
        const baseKey = k.startsWith('floor_')
          ? `floor_${tier}_base_price`
          : `${tier}_base_price`;
        const taxKey = k.startsWith('floor_')
          ? `floor_${tier}_tax_amount`
          : `${tier}_tax_amount`;
        if (baseKey in bifurcation) updateData[baseKey] = bifurcation[baseKey];
        if (taxKey in bifurcation) updateData[taxKey] = bifurcation[taxKey];
      }
    }

    await this.repo.update(id, updateData);
    return { id, message: MSG.UPDATED };
  }

  async generateSample(destinationId?: string): Promise<Buffer> {
    const rows = await this.repo.findAllForExport(destinationId);
    // Floor columns only included if caller has transport_floor_pricing:view
    const withFloor = await this.perms.hasPermission(
      ModuleName.TRANSPORT_FLOOR_PRICING,
      AccessLevel.VIEW,
    );
    return buildTransportSample(rows, withFloor);
  }

  async importFromExcel(buffer: Buffer) {
    // Parser drops floor_* fields when caller lacks floor:edit, AND
    // reports how many floor cells the user actually edited so we can
    // 403 instead of silently stripping — the previous "silent strip +
    // success toast" combo meant ops thought their floor changes had
    // landed when they hadn't.
    const acceptFloor = await this.perms.hasPermission(
      ModuleName.TRANSPORT_FLOOR_PRICING,
      AccessLevel.EDIT,
    );
    const { rows, floorEditCount } = await parseTransportImport(buffer, acceptFloor);
    if (!acceptFloor && floorEditCount > 0) {
      throw new ForbiddenException(
        `You edited ${floorEditCount} Floor price cell${floorEditCount === 1 ? '' : 's'} but lack permission to change Floor prices. Ask an admin to grant transport_floor_pricing:edit, or remove the Floor edits from the file and re-upload.`,
      );
    }

    // Bifurcate every imported row using its own destination + service
    // resolver chain. Done via callback so the shared importer doesn't
    // need to know which pricing module called it.
    return applyParsedPricingImport(rows, this.repo, {
      bifurcate: (item, prices) =>
        this.bifurcatePricesForSave(
          prices,
          item.transport_service_id,
          item.destination_id,
          item.currency_id,
        ),
    });
  }

  async remove(id: string) {
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    // Soft delete — the entity's partial unique indexes all filter on
    // WHERE is_deleted = false, so a soft-deleted row won't collide
    // with a fresh insert for the same (destination, service, mode,
    // vehicle) key. Preserves the audit trail for any snapshot the
    // accounting ledger or booking cost basis still references.
    await this.repo.softDelete(id);
    return { message: MSG.DELETED };
  }
}
