import {
  Injectable,
  NotFoundException,
  BadRequestException,
  Inject,
  forwardRef,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES } from '../../common/cls/cls.constants';
import { QueryRepository } from './repositories/query.repository';
import { CreateQueryDto, DuplicateQueryDto, UpdateQueryDto } from './dto';
import { QueryEntity, QueryStage } from '../../entities/query.entity';
import { QueryQuoteEntity } from '../../entities/query-quote.entity';
import { AuditService } from '../audit/audit.service';
import { QuoteService } from './quote.service';
import { serviceMessages } from '../../common/constants/messages';
import { UserReportingService } from '../user/user-reporting.service';

const MSG = serviceMessages('Query');
const ENTITY_TYPE = 'query';

@Injectable()
export class QueryService {
  constructor(
    private readonly repo: QueryRepository,
    private readonly auditService: AuditService,
    private readonly cls: ClsService,
    @Inject(forwardRef(() => QuoteService))
    private readonly quoteService: QuoteService,
    private readonly userReporting: UserReportingService,
  ) {}

  private async assertQueryVisible(id: string): Promise<void> {
    const visibleUserIds = await this.userReporting.resolveVisibleUserIds('query');
    if (visibleUserIds !== null && !(await this.repo.isVisibleTo(id, visibleUserIds))) {
      throw new NotFoundException(MSG.NOT_FOUND);
    }
  }

  async findAll(
    query?: { page?: number; limit?: number; search?: string },
    filters?: { stage?: string; destinationId?: string; sourceId?: string; assignedTo?: string },
  ) {
    const result = await this.repo.findPaginatedScoped({
      page: query?.page,
      limit: query?.limit,
      search: query?.search,
      filters: {
        stage: filters?.stage,
        destinationId: filters?.destinationId,
        sourceId: filters?.sourceId,
      },
      visibleUserIds: await this.userReporting.resolveVisibleUserIds('query'),
    });

    // Resolve user names for sales_team and marketing_leads
    await this.resolveUserNames(result.items);

    // Enrich each row with quote summary metrics so the listing can
    // show "N quotes · last amount" without fetching the nested
    // quotes array on the client. Picks the most recent non-deleted
    // quote by created_at; falls back to grand_total when the
    // selling_total isn't set yet.
    type EnrichedQuery = QueryEntity & {
      quote_count?: number;
      has_hotels?: boolean;
      last_quote_total?: number | null;
      last_quote_created_at?: Date | null;
    };
    for (const q of result.items as EnrichedQuery[]) {
      const quotes = Array.isArray(q.quotes)
        ? q.quotes.filter((qq: QueryQuoteEntity) => !qq.is_deleted)
        : [];
      q.quote_count = quotes.length;
      q.has_hotels = quotes.some((qq: QueryQuoteEntity) => Number(qq.hotel_total) > 0);
      if (quotes.length === 0) {
        q.last_quote_total = null;
        q.last_quote_created_at = null;
      } else {
        const sorted = [...quotes].sort(
          (a: QueryQuoteEntity, b: QueryQuoteEntity) =>
            new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
        );
        const latest = sorted[0];
        q.last_quote_total = Number(latest.selling_total) || Number(latest.grand_total) || 0;
        q.last_quote_created_at = latest.created_at;
      }
      // Drop the nested quotes array from the listing response — the
      // detail page re-fetches them anyway, and shipping them here
      // bloats the payload for a listing view.
      delete (q as Partial<EnrichedQuery>).quotes;
    }

    // Filter by assignedTo after fetch (JSON array field)
    if (filters?.assignedTo) {
      result.items = result.items.filter(
        (q: QueryEntity) =>
          Array.isArray(q.sales_team) && q.sales_team.includes(filters.assignedTo!),
      );
    }

    // Agent-roster privacy (client policy 2026-10-10): scoped (non
    // "view all") roles must not learn which agent a query came from —
    // reading/counting source names across their queries reveals the
    // roster. The list UI hides the SOURCE column for them; strip the
    // field from the payload too. List only — query detail is unchanged.
    const viewAll = this.cls.get<boolean>(CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES) === true;
    if (!viewAll) {
      for (const q of result.items as QueryEntity[]) {
        delete (q as Partial<QueryEntity>).source;
        delete (q as Partial<QueryEntity>).source_id;
      }
    }

    return result;
  }

  async findAvailable() {
    return this.repo.findAvailable(await this.userReporting.resolveVisibleUserIds('query'));
  }

  async findById(id: string) {
    // Visibility check first — return 404 (not 403) when the row
    // exists but isn't in scope. Don't leak existence outside the
    // viewer's scope.
    const visibleUserIds = await this.userReporting.resolveVisibleUserIds('query');
    if (visibleUserIds !== null && !(await this.repo.isVisibleTo(id, visibleUserIds))) {
      throw new NotFoundException(MSG.NOT_FOUND);
    }
    const item = await this.repo.findByIdWithRelations(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);
    await this.resolveUserNames([item]);
    return item;
  }

  /**
   * Resolve user names for sales_team UUID arrays.
   * Attaches sales_team_users to each query.
   */
  private async resolveUserNames(
    queries: Array<QueryEntity & { sales_team_users?: { id: string; name: string }[] }>,
  ) {
    const allIds = new Set<string>();
    for (const q of queries) {
      (q.sales_team || []).forEach((id: string) => allIds.add(id));
    }
    if (allIds.size === 0) {
      for (const q of queries) {
        q.sales_team_users = [];
      }
      return;
    }
    // tenant_id filter is mandatory on every raw query — without it
    // a sales_team UUID forged from another tenant would resolve to
    // that tenant's user name. CLS-based scoping doesn't auto-apply
    // to manager.query(), so we add the filter explicitly.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing protected helpers on TenantAwareRepository via bracket-style access
    const repoUnsafe = this.repo as any;
    const tenantId: string = repoUnsafe.getTenantId();
    const userRows: { id: string; name: string }[] = await repoUnsafe.repo.manager.query(
      `SELECT id, name FROM users WHERE id = ANY($1) AND tenant_id = $2 AND is_deleted = false`,
      [Array.from(allIds), tenantId],
    );
    const userMap = new Map<string, string>(userRows.map((u) => [u.id, u.name]));
    for (const q of queries) {
      q.sales_team_users = (q.sales_team || [])
        .filter((id: string) => userMap.has(id))
        .map((id: string) => ({ id, name: userMap.get(id)! }));
    }
  }

  async getStageCounts(filters?: { destinationId?: string; sourceId?: string; search?: string }) {
    return this.repo.countByStage(
      await this.userReporting.resolveVisibleUserIds('query'),
      filters,
    );
  }

  async create(dto: CreateQueryDto) {
    const queryNumber = await this.repo.getNextQueryNumber();

    const item = await this.repo.save({
      query_number: queryNumber,
      source_id: dto.source_id,
      ref_id: dto.ref_id || null,
      sales_team: dto.sales_team,
      source_tags: dto.source_tags || null,
      remark_tags: dto.remark_tags || null,
      destination_id: dto.destination_id,
      start_date: dto.start_date,
      end_date: dto.end_date,
      adults: dto.adults,
      children_ages: dto.children_ages || null,
      infants: dto.infants ?? 0,
      guest_name: dto.guest_name || null,
      guest_email: dto.guest_email || null,
      guest_mobile: dto.guest_mobile || null,
      guest_alt_phone: dto.guest_alt_phone || null,
      notes: dto.notes || null,
      stage: dto.stage || QueryStage.NEW,
    });

    await this.auditService.log(ENTITY_TYPE, item.id, 'created');

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

  /** Returns the booking id linked to this query, or null if none exists. */
  private async findLinkedBookingId(queryId: string): Promise<string | null> {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing protected helpers on TenantAwareRepository via bracket-style access
    const repoUnsafe = this.repo as any;
    const tenantId: string = repoUnsafe.getTenantId();
    const row: { id: string }[] = await repoUnsafe.repo.manager.query(
      `SELECT id FROM bookings WHERE query_id = $1 AND tenant_id = $2 AND is_deleted = false LIMIT 1`,
      [queryId, tenantId],
    );
    return row?.[0]?.id || null;
  }

  async update(id: string, dto: UpdateQueryDto) {
    // Visibility enforced before mutation — same 404 semantic as findById
    // so out-of-scope rows are indistinguishable from non-existent.
    await this.assertQueryVisible(id);
    const item = await this.repo.findByIdWithRelations(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    const updateData: Record<string, unknown> = {};
    const fields = [
      'source_id',
      'ref_id',
      'sales_team',
      'source_tags',
      'remark_tags',
      'destination_id',
      'start_date',
      'end_date',
      'adults',
      'children_ages',
      'infants',
      'guest_name',
      'guest_email',
      'guest_mobile',
      'guest_alt_phone',
      'notes',
      'stage',
    ] as const;
    const dtoRec = dto as unknown as Record<string, unknown>;
    for (const field of fields) {
      if (dtoRec[field] !== undefined) updateData[field] = dtoRec[field];
    }

    // Guard: cannot change destination or currency once a booking exists.
    // Pricing, itinerary dates, and supplier logic all depend on these being stable.
    const destChanging =
      updateData.destination_id !== undefined && updateData.destination_id !== item.destination_id;
    const currencyChanging =
      updateData.currency_id !== undefined && updateData.currency_id !== item.currency_id;
    if (destChanging || currencyChanging) {
      const bookingId = await this.findLinkedBookingId(id);
      if (bookingId) {
        throw new BadRequestException(
          'Cannot change destination or currency after a booking has been created. Cancel the booking first or create a new query.',
        );
      }
    }

    // Build change log with human-readable values for reference fields
    const changes: { field: string; old_value: unknown; new_value: unknown }[] = [];
    const itemRec = item as unknown as Record<string, unknown>;
    for (const field of Object.keys(updateData)) {
      const oldVal = itemRec[field];
      const newVal = updateData[field];
      if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
        if (field === 'destination_id') {
          changes.push({ field, old_value: item.destination?.name || null, new_value: newVal });
        } else if (field === 'source_id') {
          changes.push({ field, old_value: item.source?.name || null, new_value: newVal });
        } else {
          changes.push({ field, old_value: oldVal ?? null, new_value: newVal ?? null });
        }
      }
    }

    await this.repo.update(id, updateData);

    if (changes.length > 0) {
      if (updateData.source_id || updateData.destination_id) {
        const fresh = await this.repo.findByIdWithRelations(id);
        for (const change of changes) {
          if (change.field === 'source_id') {
            change.new_value = fresh?.source?.name ?? change.new_value;
          }
          if (change.field === 'destination_id') {
            change.new_value = fresh?.destination?.name ?? change.new_value;
          }
        }
      }
      await this.auditService.log(ENTITY_TYPE, id, 'updated', changes);
    }

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

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

    // Guard: cannot delete a query that has a booking
    const bookingId = await this.findLinkedBookingId(id);
    if (bookingId) {
      throw new BadRequestException(
        'This query has a booking. Cancel the booking first before deleting the query.',
      );
    }

    await this.repo.softDelete(id);
    await this.auditService.log(ENTITY_TYPE, id, 'deleted');
    return { message: MSG.DELETED };
  }

  async changeStage(id: string, stage: QueryStage) {
    await this.assertQueryVisible(id);
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    const validTransitions: Record<string, string[]> = {
      [QueryStage.NEW]: [QueryStage.IN_PROGRESS, QueryStage.CANCELLED, QueryStage.DROPPED],
      [QueryStage.IN_PROGRESS]: [
        QueryStage.CONVERTED,
        QueryStage.ON_HOLD,
        QueryStage.CANCELLED,
        QueryStage.DROPPED,
      ],
      [QueryStage.CONVERTED]: [QueryStage.ON_TRIP, QueryStage.IN_PROGRESS],
      [QueryStage.ON_HOLD]: [QueryStage.IN_PROGRESS, QueryStage.CANCELLED, QueryStage.DROPPED],
      [QueryStage.ON_TRIP]: [QueryStage.PAST_TRIP],
      [QueryStage.PAST_TRIP]: [],
      [QueryStage.CANCELLED]: [QueryStage.NEW],
      [QueryStage.DROPPED]: [QueryStage.NEW],
    };

    const allowed = validTransitions[item.stage] || [];
    if (!allowed.includes(stage)) {
      throw new BadRequestException(`Cannot change stage from "${item.stage}" to "${stage}"`);
    }

    await this.repo.update(id, { stage });

    await this.auditService.log(ENTITY_TYPE, id, 'stage_changed', [
      { field: 'stage', old_value: item.stage, new_value: stage },
    ]);

    return { id, stage, message: `Stage changed to ${stage}` };
  }

  async duplicate(id: string, dto: DuplicateQueryDto = {}) {
    await this.assertQueryVisible(id);
    const original = await this.repo.findById(id);
    if (!original) throw new NotFoundException(MSG.NOT_FOUND);

    const queryNumber = await this.repo.getNextQueryNumber();

    const newQuery = await this.repo.save({
      query_number: queryNumber,
      source_id: original.source_id,
      ref_id: original.ref_id ? `${original.ref_id}-copy` : null,
      sales_team: original.sales_team || [],
      source_tags: original.source_tags || null,
      destination_id: original.destination_id,
      start_date: original.start_date,
      end_date: original.end_date,
      adults: original.adults,
      children_ages: original.children_ages,
      infants: original.infants,
      guest_name: original.guest_name,
      guest_mobile: original.guest_mobile,
      guest_email: original.guest_email,
      guest_alt_phone: original.guest_alt_phone,
      notes: original.notes
        ? `Duplicated from ${original.query_number || id}. ${original.notes}`
        : `Duplicated from ${original.query_number || id}`,
      remark_tags: original.remark_tags,
      stage: QueryStage.NEW,
      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- enabled_services is a forward-compat optional field not yet in QueryEntity
      enabled_services: (original as any).enabled_services,
    } as Partial<QueryEntity>);

    // Duplicate all quotes from the source query into the newly-created one.
    // Hotels are opt-in (UAT 2026-06-30): a new inquiry usually has
    // different dates, and a stale hotel selection carries through to the
    // booking + Movement Dashboard. The user chooses via `include_hotels`.
    const originalQuotes = await this.quoteService.findByQueryId(id);
    for (const quote of originalQuotes) {
      await this.quoteService.duplicateQuote(id, quote.id, newQuery.id, {
        skipHotelItems: !dto.include_hotels,
      });
    }

    await this.auditService.log(ENTITY_TYPE, newQuery.id, 'created');
    return {
      id: newQuery.id,
      query_number: newQuery.query_number,
      message: 'Query duplicated with all quotes',
    };
  }
}
