import {
  Injectable,
  NotFoundException,
  ConflictException,
  BadRequestException,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, In, IsNull, Repository } from 'typeorm';
import { CLS_USER_ID } from '../../common/cls/cls.constants';
import { StorageService } from '../../common/storage/storage.service';
import { TenantEntity } from '../../entities/tenant.entity';
import { ActivityTicketBatchRepository } from './repositories/activity-ticket-batch.repository';
import { ActivityTicketItemRepository } from './repositories/activity-ticket-item.repository';
import { ActivityTicketJourneyRepository } from './repositories/activity-ticket-journey.repository';
import { CreateActivityTicketBatchDto } from './dto/create-activity-ticket-batch.dto';
import { UpdateActivityTicketBatchDto } from './dto/update-activity-ticket-batch.dto';
import { TopUpBatchDto } from './dto/topup-batch.dto';
import { AssignTicketDto } from './dto/assign-ticket.dto';
import { UpdateTicketItemDto } from './dto/update-ticket-item.dto';
import {
  ActivityTicketBatchEntity,
  TicketBatchStatus,
} from '../../entities/activity-ticket-batch.entity';
import {
  ActivityTicketItemEntity,
  TicketItemStatus,
} from '../../entities/activity-ticket-item.entity';
import { TicketJourneyEvent } from '../../entities/activity-ticket-journey.entity';
import { BookingActivityItemEntity } from '../../entities/booking-activity-item.entity';
import { nextTicketStatus } from '../../common/itinerary/ticket-status';

@Injectable()
export class ActivityTicketService {
  constructor(
    private readonly batchRepo: ActivityTicketBatchRepository,
    private readonly itemRepo: ActivityTicketItemRepository,
    private readonly journeyRepo: ActivityTicketJourneyRepository,
    private readonly cls: ClsService,
    private readonly storage: StorageService,
    @InjectRepository(TenantEntity)
    private readonly tenantRepo: Repository<TenantEntity>,
  ) {}

  /**
   * Line ticket status follows assignment: Booked as soon as a ticket
   * is uploaded/assigned, back to Pending when the last ticket is
   * returned to batch. Runs inside the caller's transaction; tenant
   * scoped explicitly (raw EntityManager).
   */
  private async syncLineTicketStatus(
    em: EntityManager,
    tenantId: string,
    bookingActivityItemId: string,
    userId: string | null,
  ): Promise<void> {
    const lineRepo = em.getRepository(BookingActivityItemEntity);
    const line = await lineRepo.findOne({
      where: { id: bookingActivityItemId, tenant_id: tenantId, is_deleted: false },
    });
    if (!line) return;
    const siblings = await lineRepo.find({
      where: {
        booking_id: line.booking_id,
        activity_id: line.activity_id,
        // Raw null in a find-where throws — match NULL explicitly.
        ticket_id: line.ticket_id ?? IsNull(),
        date: line.date ?? IsNull(),
        tenant_id: tenantId,
        is_deleted: false,
      } as Record<string, unknown>,
    });
    const tickets = await em.getRepository(ActivityTicketItemEntity).find({
      where: {
        booking_activity_item_id: In(siblings.map((r) => r.id)),
        tenant_id: tenantId,
        status: TicketItemStatus.ASSIGNED,
        is_deleted: false,
      },
    });
    const next = nextTicketStatus(line.ticket_status, tickets.length);
    if (next === line.ticket_status) return;
    await lineRepo.update(
      { id: In(siblings.map((r) => r.id)), tenant_id: tenantId },
      { ticket_status: next, updated_by: userId },
    );
  }

  /**
   * Places a ticket file into the `unused/` or `used/` sub-folder.
   *
   * A freshly uploaded key `{subdomain}/{filename}` is moved into
   * `{subdomain}/{toFolder}/{filename}` (first placement, at batch
   * create/top-up); an already-foldered key
   * `{subdomain}/{used|unused}/{filename}` just has that folder segment
   * swapped (assign/unassign). Best-effort: a storage hiccup leaves
   * the original URL untouched rather than failing the (un)assignment.
   *
   * Keys must belong to `tenantPrefix` (current tenant subdomain).
   * Crafted / cross-tenant / traversal URLs are dropped (null) so they
   * cannot be persisted on a ticket row.
   */
  private async moveTicketFile(
    documentUrl: string | null,
    toFolder: 'used' | 'unused',
    tenantPrefix: string | null,
  ): Promise<string | null> {
    if (!documentUrl) return null;
    if (!tenantPrefix) return null;

    const key = this.storage.keyFromUrl(documentUrl);
    if (!key) {
      // Not a URL we issued. Drop anything that looks like an upload
      // path (or a script URL) so a crafted href cannot land on a row.
      // Genuine external/legacy URLs without our mount are left as-is.
      if (
        /\/api\/uploads\//.test(documentUrl) ||
        /^\s*javascript:/i.test(documentUrl) ||
        /^\s*data:/i.test(documentUrl)
      ) {
        return null;
      }
      return documentUrl;
    }

    const parts = key.split('/');
    if (parts[0] !== tenantPrefix) return null;

    const filename = parts[parts.length - 1];

    if (parts.length === 3) {
      const fromFolder = parts[1];
      if (fromFolder !== 'used' && fromFolder !== 'unused') return null;
      if (fromFolder === toFolder) return documentUrl;
    } else if (parts.length !== 2) {
      return null;
    }

    const toKey = `${tenantPrefix}/${toFolder}/${filename}`;
    try {
      await this.storage.move(key, toKey);
      return this.storage.url(toKey);
    } catch {
      return documentUrl;
    }
  }

  private async currentTenantPrefix(): Promise<string | null> {
    const tenantId = this.batchRepo.currentTenantId();
    const tenant = await this.tenantRepo.findOne({
      where: { id: tenantId },
      select: { subdomain: true },
    });
    return tenant?.subdomain ?? null;
  }

  async findAllBatches(params: {
    page?: number;
    limit?: number;
    search?: string;
    activity_id?: string;
    status?: TicketBatchStatus;
  }) {
    return this.batchRepo.findAllPaginated(params);
  }

  async findBatchById(id: string) {
    const batch = await this.batchRepo.findByIdWithItems(id);
    if (!batch) throw new NotFoundException('Ticket batch not found');
    return batch;
  }

  /**
   * Bookings whose travel window fits inside the batch's validity AND
   * which actually use this batch's activity. Returns one row per
   * booking with the per-booking activity-item summary so the FE can
   * render an inline "Assign all" / "Already assigned" indicator.
   *
   * Match rules:
   *   • booking.is_deleted = false
   *   • query.destination_id = batch.activity.destination_id
   *     (filter is implicit via the activity_items join, no separate check)
   *   • at least one booking_activity_items row with activity_id =
   *     batch.activity_id
   *   • query.start_date >= batch.valid_from
   *   • query.end_date   <= batch.valid_to
   *     (when valid_from / valid_to are NULL, that side of the window
   *     is treated as "open" — every booking matches that bound)
   *
   * No ticket-type / age-group filter on the booking side — ops can
   * assign a "General Admission Adult" batch even if the booking line
   * was marked "child" by clerical error; the inventory page is for
   * planning visibility, not strict validation.
   */
  async findMatchingBookings(batchId: string) {
    const batch = await this.batchRepo.findById(batchId);
    if (!batch) throw new NotFoundException('Ticket batch not found');
    const tenantId = this.batchRepo.currentTenantId();

    // Hand-rolled query — joins query + bookings + booking_activity_items
    // with conditional date bounds. Treats NULL valid_from / valid_to
    // as "open" via COALESCE to the booking's own bounds.
    const rows = (await this.batchRepo.manager.query(
      `
      SELECT
        b.id                AS booking_id,
        b.booking_number    AS booking_number,
        q.guest_name        AS guest_name,
        q.guest_mobile      AS guest_mobile,
        q.start_date        AS start_date,
        q.end_date          AS end_date,
        q.adults            AS adults,
        q.children_ages     AS children_ages,
        COUNT(DISTINCT ai.id)                                                AS activity_items,
        COUNT(DISTINCT tic.id) FILTER (WHERE tic.batch_id = $1)               AS already_assigned
        FROM bookings b
        JOIN queries q ON q.id = b.query_id
        JOIN booking_activity_items ai
          ON ai.booking_id = b.id
         AND ai.activity_id = $2
         AND ai.is_deleted = false
        LEFT JOIN activity_ticket_items tic
          ON tic.booking_activity_item_id = ai.id
       WHERE b.tenant_id = $3
         AND b.is_deleted = false
         AND q.start_date >= COALESCE($4::date, q.start_date)
         AND q.end_date   <= COALESCE($5::date, q.end_date)
       GROUP BY b.id, q.guest_name, q.guest_mobile, q.start_date, q.end_date, q.adults, q.children_ages
       ORDER BY q.start_date ASC, b.booking_number ASC
      `,
      [batchId, batch.activity_id, tenantId, batch.valid_from, batch.valid_to],
    )) as Array<{
      booking_id: string;
      booking_number: string;
      guest_name: string | null;
      guest_mobile: string | null;
      start_date: string;
      end_date: string;
      adults: number;
      children_ages: string | null;
      activity_items: string;
      already_assigned: string;
    }>;

    return rows.map((r) => ({
      booking_id: r.booking_id,
      booking_number: r.booking_number,
      guest_name: r.guest_name,
      guest_mobile: r.guest_mobile,
      start_date: r.start_date,
      end_date: r.end_date,
      adults: Number(r.adults || 0),
      children_count: r.children_ages
        ? (() => {
            try {
              const parsed = JSON.parse(r.children_ages);
              return Array.isArray(parsed) ? parsed.length : 0;
            } catch {
              return 0;
            }
          })()
        : 0,
      activity_items: Number(r.activity_items),
      already_assigned: Number(r.already_assigned),
    }));
  }

  async findActiveBatchesForActivity(activityId: string) {
    return this.batchRepo.findActiveBatchesForActivity(activityId);
  }

  async createBatch(dto: CreateActivityTicketBatchDto) {
    const { items, ...batchData } = dto;

    // Group items by ticket_type + age_group — one batch per unique combination
    const groups = new Map<string, typeof items>();
    for (const item of items) {
      const key = `${item.ticket_type}||${item.age_group}`;
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key)!.push(item);
    }

    const created = [];
    for (const [key, groupItems] of groups) {
      const [ticket_type, age_group] = key.split('||');
      const batch = await this.batchRepo.save({
        ...batchData,
        ticket_type,
        age_group,
        total_qty: groupItems.length,
        available_qty: groupItems.length,
        status: TicketBatchStatus.ACTIVE,
        booking_reference_no: dto.booking_reference_no ?? null,
      });
      await this._addItemsToBatch(batch.id, groupItems);
      created.push(batch);
    }

    const count = created.length;
    return {
      message: `${count} ticket batch${count > 1 ? 'es' : ''} created successfully`,
      batches: created,
    };
  }

  async updateBatch(id: string, dto: UpdateActivityTicketBatchDto) {
    const batch = await this.batchRepo.findById(id);
    if (!batch) throw new NotFoundException('Ticket batch not found');
    // `items` is now omitted at the DTO level — see update-activity-ticket-batch.dto.ts.
    await this.batchRepo.update(id, dto);
    return { message: 'Ticket batch updated successfully' };
  }

  async topUpBatch(id: string, dto: TopUpBatchDto) {
    const batch = await this.batchRepo.findById(id);
    if (!batch) throw new NotFoundException('Ticket batch not found');
    if (batch.status === TicketBatchStatus.CANCELLED) {
      throw new BadRequestException('Cannot top up a cancelled batch');
    }

    await this._addItemsToBatch(id, dto.items);

    const addedCount = dto.items.length;
    await this.batchRepo.update(id, {
      total_qty: batch.total_qty + addedCount,
      available_qty: batch.available_qty + addedCount,
      status: TicketBatchStatus.ACTIVE,
    });

    return { message: `${addedCount} ticket(s) added to batch` };
  }

  async deleteBatch(id: string) {
    const batch = await this.batchRepo.findById(id);
    if (!batch) throw new NotFoundException('Ticket batch not found');
    if (batch.available_qty < batch.total_qty) {
      throw new ConflictException('Cannot delete batch with assigned tickets');
    }
    await this.batchRepo.softDelete(id);
    return { message: 'Ticket batch deleted successfully' };
  }

  // Called from Movement Dashboard — assigns next available ticket in batch to a booking activity item
  async assignTicket(dto: AssignTicketDto) {
    const tenantId = this.batchRepo.currentTenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;

    // Everything that touches the inventory counter runs inside one
    // transaction with a row lock on the batch. Without the lock two
    // concurrent assigns could grab the SAME available ticket (one guest
    // silently loses it) and both decrement available_qty from the same
    // stale read — classic inventory over-allocation. FOR UPDATE on the
    // batch serialises assign/unassign for that batch; the ticket pick
    // then reads a consistent state.
    const result = await this.batchRepo.manager.transaction(async (em) => {
      const lockedBatch = (await em.query(
        `SELECT available_qty, status
           FROM activity_ticket_batches
          WHERE id = $1 AND tenant_id = $2 AND is_deleted = false
          FOR UPDATE`,
        [dto.batch_id, tenantId],
      )) as Array<{ available_qty: number; status: string }>;
      const batch = lockedBatch[0];
      if (!batch) throw new NotFoundException('Ticket batch not found');
      if (batch.status !== TicketBatchStatus.ACTIVE || Number(batch.available_qty) <= 0) {
        throw new BadRequestException('No available tickets in this batch');
      }

      const itemRepo = em.getRepository(ActivityTicketItemEntity);
      const item = await itemRepo.findOne({
        where: {
          batch_id: dto.batch_id,
          tenant_id: tenantId,
          status: TicketItemStatus.AVAILABLE,
          is_deleted: false,
        },
        order: { ticket_ref_no: 'ASC' },
      });
      if (!item) throw new BadRequestException('No available ticket found in batch');

      // Move file from /unused/ to /used/ (best-effort, path-hardened).
      const tenantPrefix = await this.currentTenantPrefix();
      const newUrl = await this.moveTicketFile(item.document_url, 'used', tenantPrefix);

      await itemRepo.update(
        { id: item.id, tenant_id: tenantId },
        {
          status: TicketItemStatus.ASSIGNED,
          booking_activity_item_id: dto.booking_activity_item_id,
          assigned_by: userId,
          assigned_at: new Date(),
          document_url: newUrl,
          updated_by: userId,
          ...(dto.dispatched_via
            ? { dispatched_via: dto.dispatched_via, dispatched_at: new Date() }
            : {}),
        },
      );

      // Decrement is race-free: the batch row is locked FOR UPDATE, so the
      // value read above is authoritative for the life of the transaction.
      const newAvailable = Number(batch.available_qty) - 1;
      await em.getRepository(ActivityTicketBatchEntity).update(
        { id: dto.batch_id, tenant_id: tenantId },
        {
          available_qty: newAvailable,
          updated_by: userId,
          ...(newAvailable <= 0 ? { status: TicketBatchStatus.EXHAUSTED } : {}),
        },
      );

      await this.syncLineTicketStatus(em, tenantId, dto.booking_activity_item_id, userId);

      return {
        itemId: item.id,
        ticketRefNo: item.ticket_ref_no,
        fromUrl: item.document_url,
        newUrl,
      };
    });

    // Journey is an append-only audit row — safe to log after commit.
    await this.journeyRepo.logEvent({
      ticket_item_id: result.itemId,
      event: TicketJourneyEvent.ASSIGNED,
      booking_activity_item_id: dto.booking_activity_item_id,
      from_path: result.fromUrl,
      to_path: result.newUrl,
    });

    return {
      message: 'Ticket assigned successfully',
      ticket_id: result.itemId,
      ticket_ref_no: result.ticketRefNo,
    };
  }

  // Returns ticket to batch (called on cancellation from Movement Dashboard)
  async unassignTicket(ticketItemId: string) {
    const tenantId = this.batchRepo.currentTenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;

    // Same locking discipline as assignTicket: the batch counter mutation
    // must serialise with concurrent assigns, so hold FOR UPDATE on the
    // batch for the whole item-return + increment.
    const result = await this.batchRepo.manager.transaction(async (em) => {
      const itemRepo = em.getRepository(ActivityTicketItemEntity);
      const item = await itemRepo.findOne({
        where: { id: ticketItemId, tenant_id: tenantId, is_deleted: false },
      });
      if (!item) throw new NotFoundException('Ticket not found');
      if (item.status !== TicketItemStatus.ASSIGNED) {
        throw new BadRequestException('Only assigned tickets can be unassigned');
      }

      const lockedBatch = (await em.query(
        `SELECT available_qty, status
           FROM activity_ticket_batches
          WHERE id = $1 AND tenant_id = $2 AND is_deleted = false
          FOR UPDATE`,
        [item.batch_id, tenantId],
      )) as Array<{ available_qty: number; status: string }>;

      // Move file from /used/ back to /unused/ (best-effort, path-hardened).
      const tenantPrefix = await this.currentTenantPrefix();
      const newUrl = await this.moveTicketFile(item.document_url, 'unused', tenantPrefix);

      await itemRepo.update(
        { id: ticketItemId, tenant_id: tenantId },
        {
          status: TicketItemStatus.AVAILABLE,
          booking_activity_item_id: null,
          assigned_by: null,
          assigned_at: null,
          dispatched_via: null,
          dispatched_at: null,
          document_url: newUrl,
          updated_by: userId,
        },
      );

      const batch = lockedBatch[0];
      if (batch) {
        await em.getRepository(ActivityTicketBatchEntity).update(
          { id: item.batch_id, tenant_id: tenantId },
          {
            available_qty: Number(batch.available_qty) + 1,
            updated_by: userId,
            // Only revive an EXHAUSTED batch — never resurrect one that
            // ops CANCELLED / that EXPIRED just because a ticket came back.
            ...(batch.status === TicketBatchStatus.EXHAUSTED
              ? { status: TicketBatchStatus.ACTIVE }
              : {}),
          },
        );
      }

      if (item.booking_activity_item_id) {
        await this.syncLineTicketStatus(em, tenantId, item.booking_activity_item_id, userId);
      }

      return {
        fromUrl: item.document_url,
        newUrl,
        bookingActivityItemId: item.booking_activity_item_id,
      };
    });

    await this.journeyRepo.logEvent({
      ticket_item_id: ticketItemId,
      event: TicketJourneyEvent.UNASSIGNED,
      booking_activity_item_id: result.bookingActivityItemId,
      from_path: result.fromUrl,
      to_path: result.newUrl,
    });

    return { message: 'Ticket returned to batch' };
  }

  async updateTicketItem(ticketItemId: string, dto: UpdateTicketItemDto) {
    const item = await this.itemRepo.findById(ticketItemId);
    if (!item) throw new NotFoundException('Ticket not found');
    if (dto.document_url !== undefined) {
      const folder = item.status === TicketItemStatus.ASSIGNED ? 'used' : 'unused';
      const parked = await this.moveTicketFile(
        dto.document_url,
        folder,
        await this.currentTenantPrefix(),
      );
      dto = { ...dto, document_url: parked ?? undefined };
    }
    await this.itemRepo.update(ticketItemId, dto);
    return { message: 'Ticket updated successfully' };
  }

  async getTicketsForBookingActivityItem(bookingActivityItemId: string) {
    return this.itemRepo.findByBookingActivityItem(bookingActivityItemId);
  }

  /** Returns active batches whose booking_reference_no matches the given bookingId. */
  async findBatchesForBooking(bookingId: string, activityId?: string) {
    return this.batchRepo.findBatchesForBooking(bookingId, activityId);
  }

  /** Full journey/history log for a ticket item. */
  async getTicketJourney(ticketItemId: string) {
    return this.journeyRepo.findByTicketItem(ticketItemId);
  }

  private async _addItemsToBatch(
    batchId: string,
    items: { ticket_ref_no: string; document_url?: string; notes?: string }[],
  ) {
    const tenantPrefix = await this.currentTenantPrefix();
    for (const item of items) {
      // Park the uploaded PDF in the tenant's `unused/` folder so the
      // folder state tracks the ticket lifecycle from birth; assigning it
      // via the Movement Dashboard later moves the file to `used/`.
      const documentUrl = await this.moveTicketFile(
        item.document_url ?? null,
        'unused',
        tenantPrefix,
      );
      await this.itemRepo.save({
        batch_id: batchId,
        ticket_ref_no: item.ticket_ref_no,
        document_url: documentUrl,
        notes: item.notes ?? null,
        status: TicketItemStatus.AVAILABLE,
      });
    }
  }
}
