import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder, FindOptionsWhere, Repository } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { ActivityRepository } from './repositories/activity.repository';
import { ActivityTicketRepository } from './repositories/activity-ticket.repository';
import { ActivityMediaRepository } from './repositories/activity-media.repository';
import { MediaService } from '../../common/media/media.service';
import { ActivityEntity } from '../../entities/activity.entity';
import { CreateActivityDto, UpdateActivityDto } from './dto';
import { serviceMessages } from '../../common/constants/messages';
import { deriveUniqueShortCode } from '../../common/utils/short-code';

type AgeConfigItem = {
  label: string;
  min_age: number;
  max_age: number;
  enabled?: boolean;
};

// Repository internals (cls, manager, getTenantId) are protected on
// TenantAwareRepository. Activity auto-syncs pricing rows via the
// EntityManager and needs CLS context — narrow the unsafe cast to this
// shape rather than reaching through `any`.
interface RepoInternals {
  getTenantId(): string;
  cls: ClsService;
  repo: Repository<ActivityEntity>;
}

const MSG = serviceMessages('Activity');
const DEFAULT_AGE_CONFIG = [
  { label: 'Child', min_age: 0, max_age: 12 },
  { label: 'Adult', min_age: 13, max_age: 59 },
  { label: 'Senior', min_age: 60, max_age: 99 },
];

@Injectable()
export class ActivityService {
  constructor(
    private readonly repo: ActivityRepository,
    private readonly ticketRepo: ActivityTicketRepository,
    private readonly mediaRepo: ActivityMediaRepository,
    private readonly media: MediaService,
  ) {}

  /**
   * Replace a ticket's slot rows with the incoming set. Per-ticket
   * lifecycle is owned by the activity form, so the simplest reliable
   * sync is hard-delete-then-insert. Idempotent.
   *
   * Skips entirely when `incoming` is `undefined` (caller didn't
   * touch slots); pass `[]` to clear all slots.
   */
  private async syncTicketSlots(
    ticketId: string,
    incoming: { label: string; start_time?: string | null; end_time?: string | null }[] | undefined,
  ): Promise<void> {
    if (incoming === undefined) return;
    const internals = this.repo as unknown as RepoInternals;
    const tenantId = internals.getTenantId();
    const userId = internals.cls?.get<string>('user_id') || null;
    const manager = internals.repo.manager;
    await manager.query(
      `DELETE FROM activity_ticket_slots WHERE tenant_id = $1 AND ticket_id = $2`,
      [tenantId, ticketId],
    );
    if (incoming.length === 0) return;
    // Insert in one round-trip — preserves the FE-supplied order via
    // sort_order so reads come back stable.
    const cols = [
      'tenant_id',
      'ticket_id',
      'label',
      'start_time',
      'end_time',
      'sort_order',
      'created_by',
      'updated_by',
    ];
    const values: unknown[] = [];
    const rows = incoming.map((s, i) => {
      const base = values.length;
      values.push(tenantId, ticketId, s.label, s.start_time || null, s.end_time || null, i, userId, userId);
      return `(${cols.map((_, j) => `$${base + j + 1}`).join(', ')})`;
    });
    await manager.query(
      `INSERT INTO activity_ticket_slots (${cols.join(', ')}) VALUES ${rows.join(', ')}`,
      values,
    );
  }

  async findAll(
    query?: { page?: number; limit?: number; search?: string },
    filters?: { destinationId?: string },
  ) {
    const where: FindOptionsWhere<ActivityEntity> = {};
    if (filters?.destinationId) where.destination_id = filters.destinationId;

    return this.repo.findPaginated({
      page: query?.page,
      limit: query?.limit,
      search: query?.search,
      // Search covers the identity columns + long-form text so ops
      // can find an activity by a keyword from its content (e.g.
      // "Jumeirah Mosque" surfacing Dubai City Tour, or "dune bashing"
      // surfacing Desert Safari via customer_whatsapp_body). short_code
      // is included so BK-style refs work verbatim.
      searchColumns: [
        'name',
        'short_code',
        'address',
        'location',
        'contact_person',
        'description',
        'special_instructions',
        'meeting_point',
        'customer_whatsapp_body',
        'supplier_whatsapp_body',
      ],
      relations: ['destination', 'tickets'],
      order: { name: 'ASC' },
      where,
    });
  }

  async findAllForLookup() {
    return this.repo.find({
      where: { is_active: true },
      select: {
        id: true,
        name: true,
        short_code: true,
        category: true,
        destination_id: true,
        age_config: true,
        operating_days: true,
        maintenance_periods: true,
        cutoff_type: true,
        cutoff_value: true,
        has_transfer: true,
        is_special: true,
      },
      relations: {
        tickets: true,
      },
      order: { name: 'ASC' } as FindOptionsOrder<ActivityEntity>,
    });
  }

  async findById(id: string) {
    const item = await this.repo.findByIdWithRelations(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);
    // Filter out soft-deleted tickets
    if (item.tickets) {
      item.tickets = item.tickets.filter((t) => !t.is_deleted);
    }
    return { ...item, media: await this.media.list(this.mediaRepo, id) };
  }

  async create(dto: CreateActivityDto) {
    const existing = await this.repo.findByNameAndDestination(dto.name, dto.destination_id);
    if (existing) throw new ConflictException(MSG.ALREADY_EXISTS('name'));
    const media = await this.media.prepare(this.mediaRepo, 'activities', dto.media);

    // short_code is auto-derived from the name and tenant-unique.
    // UI no longer collects it; we tolerate a DTO value for seeders
    // and Excel imports.
    const shortCode =
      dto.short_code ||
      (await deriveUniqueShortCode(dto.name, 'ACT', (code) =>
        this.repo.findByShortCode(code).then(Boolean),
      ));

    const item = await this.repo.save({
      destination_id: dto.destination_id,
      short_code: shortCode,
      category: dto.category,
      name: dto.name,
      address: dto.address,
      location: dto.location || null,
      cutoff_type: dto.cutoff_type || null,
      cutoff_value: dto.cutoff_value || null,
      operating_days: dto.operating_days || null,
      maintenance_periods: dto.maintenance_periods || null,
      gps_latitude: dto.gps_latitude || null,
      gps_longitude: dto.gps_longitude || null,
      contact_person: dto.contact_person,
      contact_phone: dto.contact_phone || null,
      description: dto.description || null,
      special_instructions: dto.special_instructions || null,
      meeting_point: dto.meeting_point || null,
      age_config: dto.age_config || DEFAULT_AGE_CONFIG,
      is_active: dto.is_active ?? true,
      has_transfer: dto.has_transfer ?? false,
      is_special: dto.is_special ?? false,
    });
    await this.media.sync(this.mediaRepo, item.id, media);

    // Create tickets if provided. time_slot dropped — slots are persisted
    // separately via activity_ticket_slots (Phase 1 add-only flow).
    if (dto.tickets?.length) {
      for (const ticket of dto.tickets) {
        const saved = await this.ticketRepo.save({
          activity_id: item.id,
          name: ticket.name,
          internal_ref: ticket.internal_ref || null,
          duration_mins: ticket.duration_mins ?? null,
          description: ticket.description || null,
          is_active: ticket.is_active ?? true,
        });
        await this.syncTicketSlots(saved.id, ticket.slots);
      }
    }

    // Auto-create activity pricing for each enabled age group + ticket
    try {
      const internals = this.repo as unknown as RepoInternals;
      const tenantId = internals.getTenantId();
      const userId = internals.cls?.get<string>('user_id') || null;
      const manager = internals.repo.manager;

      const destRows: { currency_id: string | null }[] = await manager.query(
        `SELECT currency_id FROM destinations WHERE id = $1 LIMIT 1`,
        [dto.destination_id],
      );
      const currencyId = destRows[0]?.currency_id;

      if (currencyId) {
        const ageConfig: AgeConfigItem[] = dto.age_config || DEFAULT_AGE_CONFIG;
        const AGE_MAP: Record<string, string> = {
          Child: 'child',
          Adult: 'adult',
          Senior: 'senior',
        };
        const enabledGroups = ageConfig
          .filter((ag) => ag.enabled !== false && (ag.min_age > 0 || ag.max_age > 0))
          .map((ag) => AGE_MAP[ag.label] || ag.label.toLowerCase());

        // Get ticket IDs from the saved tickets
        const savedTickets = await this.ticketRepo.findByActivityId(item.id);

        for (const ticket of savedTickets.length > 0 ? savedTickets : [null]) {
          for (const ageGroup of enabledGroups) {
            await manager.save('activity_pricing', {
              tenant_id: tenantId,
              destination_id: dto.destination_id,
              activity_id: item.id,
              ticket_id: ticket?.id || null,
              age_group: ageGroup,
              currency_id: currencyId,
              created_by: userId,
              updated_by: userId,
            });
          }
        }
      }
    } catch {
      /* Don't fail activity creation if pricing auto-create fails */
    }

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

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

    if (dto.name || dto.destination_id) {
      const checkName = dto.name || item.name;
      const checkDest = dto.destination_id || item.destination_id;
      if (checkName !== item.name || checkDest !== item.destination_id) {
        const existing = await this.repo.findByNameAndDestination(checkName, checkDest);
        if (existing && existing.id !== id) throw new ConflictException(MSG.ALREADY_EXISTS('name'));
      }
    }
    const media = await this.media.prepare(this.mediaRepo, 'activities', dto.media, id);

    // short_code is immutable post-create — strip it from the payload
    // so a stale form value can't accidentally rewrite it.
    const updateData: Partial<ActivityEntity> = {};
    const fields = [
      'destination_id',
      'category',
      'name',
      'address',
      'location',
      'cutoff_type',
      'cutoff_value',
      'operating_days',
      'maintenance_periods',
      'gps_latitude',
      'gps_longitude',
      'contact_person',
      'contact_phone',
      'description',
      'special_instructions',
      'meeting_point',
      'age_config',
      'is_active',
      'has_transfer',
      'is_special',
      // Per-activity WhatsApp body (Phase D2b). Must be in this
      // whitelist for PATCHes to persist — the activity update
      // copies DTO → entity field by field, so anything missing
      // here is silently dropped even if the DTO validates.
      'customer_whatsapp_body',
      'supplier_whatsapp_body',
    ];
    for (const field of fields) {
      const value = (dto as Record<string, unknown>)[field];
      if (value !== undefined) {
        (updateData as Record<string, unknown>)[field] = value;
      }
    }

    await this.repo.update(id, updateData);
    await this.media.sync(this.mediaRepo, id, media);

    // Handle tickets if provided
    const newlyCreatedTicketIds: string[] = [];
    if (dto.tickets !== undefined) {
      const incomingTickets = dto.tickets || [];
      const existingTickets = await this.ticketRepo.findByActivityId(id);

      // Upsert incoming tickets
      const incomingIds = new Set<string>();
      for (const ticket of incomingTickets) {
        if (ticket.id) {
          // Update existing ticket — pick only valid fields. Slot rows
          // live in activity_ticket_slots (Phase 1).
          incomingIds.add(ticket.id);
          await this.ticketRepo.update(ticket.id, {
            name: ticket.name,
            internal_ref: ticket.internal_ref || null,
            duration_mins: ticket.duration_mins ?? null,
            description: ticket.description || null,
            is_active: ticket.is_active ?? true,
          });
          await this.syncTicketSlots(ticket.id, ticket.slots);
        } else {
          // Create new ticket
          const saved = await this.ticketRepo.save({
            activity_id: id,
            name: ticket.name,
            internal_ref: ticket.internal_ref || null,
            duration_mins: ticket.duration_mins ?? null,
            description: ticket.description || null,
            is_active: ticket.is_active ?? true,
          });
          incomingIds.add(saved.id);
          newlyCreatedTicketIds.push(saved.id);
          await this.syncTicketSlots(saved.id, ticket.slots);
        }
      }

      // Soft-delete tickets not in incoming list
      for (const existing of existingTickets) {
        if (!incomingIds.has(existing.id)) {
          await this.ticketRepo.softDelete(existing.id);
        }
      }
    }

    // Auto-sync activity_pricing — fill any missing (ticket × age_group)
    // rows so the Activity Pricing page stays a mirror of masters. Covers:
    //   - newly-created tickets on an existing activity
    //   - age_config edits that enable a previously-disabled age group
    // Uses upsert-style "skip if exists" keyed by the composite UNIQUE
    // (tenant, activity, ticket, age_group, currency).
    try {
      const ageConfigChanged = dto.age_config !== undefined;
      if (newlyCreatedTicketIds.length > 0 || ageConfigChanged) {
        const internals = this.repo as unknown as RepoInternals;
        const tenantId = internals.getTenantId();
        const userId = internals.cls?.get<string>('user_id') || null;
        const manager = internals.repo.manager;
        const activity = await this.repo.findById(id);
        const destRows: { currency_id: string | null }[] = activity?.destination_id
          ? await manager.query(`SELECT currency_id FROM destinations WHERE id = $1 LIMIT 1`, [
              activity.destination_id,
            ])
          : [];
        const currencyId = destRows[0]?.currency_id;
        if (currencyId) {
          const ageConfig: AgeConfigItem[] = activity?.age_config || DEFAULT_AGE_CONFIG;
          const AGE_MAP: Record<string, string> = {
            Child: 'child',
            Adult: 'adult',
            Senior: 'senior',
          };
          const enabledGroups = ageConfig
            .filter((ag) => ag.enabled !== false && (ag.min_age > 0 || ag.max_age > 0))
            .map((ag) => AGE_MAP[ag.label] || ag.label.toLowerCase());

          const tickets = await this.ticketRepo.findByActivityId(id);
          const ticketIds: (string | null)[] = tickets.length ? tickets.map((t) => t.id) : [null];

          for (const ticketId of ticketIds) {
            for (const ageGroup of enabledGroups) {
              // Upsert — rely on unique constraint + ON CONFLICT DO NOTHING
              await manager
                .createQueryBuilder()
                .insert()
                .into('activity_pricing')
                .values({
                  tenant_id: tenantId,
                  destination_id: activity!.destination_id,
                  activity_id: id,
                  ticket_id: ticketId,
                  age_group: ageGroup,
                  currency_id: currencyId,
                  created_by: userId,
                  updated_by: userId,
                })
                .orIgnore()
                .execute();
            }
          }
        }
      }
    } catch {
      /* don't fail update if sync fails */
    }

    return { id, message: MSG.UPDATED };
  }

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

    // Soft-delete all tickets first
    const tickets = await this.ticketRepo.findByActivityId(id);
    for (const ticket of tickets) {
      await this.ticketRepo.softDelete(ticket.id);
    }

    await this.repo.softDelete(id);
    return { message: MSG.DELETED };
  }
}
