import {
  Injectable,
  ConflictException,
  NotFoundException,
  BadRequestException,
  Logger,
} from '@nestjs/common';
import { TripSourceRepository } from './repositories/trip-source.repository';
import { TripSourceMediaRepository } from './repositories/trip-source-media.repository';
import { MediaService } from '../../common/media/media.service';
import { AgentCostRepository } from '../agent-cost/repositories/agent-cost.repository';
import { CreateTripSourceDto, UpdateTripSourceDto } from './dto';
import { TripSourceEntity } from '../../entities/trip-source.entity';
import { serviceMessages } from '../../common/constants/messages';
import { deriveUniqueShortCode } from '../../common/utils/short-code';
import { ILike, type FindOptionsWhere } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES } from '../../common/cls/cls.constants';

const MSG = serviceMessages('Trip source');
const AGENT_COST_INVALID = 'Agent cost was not found or is inactive';

@Injectable()
export class TripSourceService {
  private readonly logger = new Logger(TripSourceService.name);

  constructor(
    private readonly repo: TripSourceRepository,
    private readonly agentCosts: AgentCostRepository,
    private readonly cls: ClsService,
    private readonly mediaRepo: TripSourceMediaRepository,
    private readonly media: MediaService,
  ) {}

  async findAll(
    query?: { page?: number; limit?: number; search?: string },
    filters?: { sourceType?: string; status?: string; agentCostId?: string },
  ) {
    // Agent-roster privacy (client policy 2026-10-10): only "view all"
    // roles may browse the full agent list. Everyone else must search
    // (3+ chars) to load matches — same rationale as the Query/Booking
    // Source picker. Enforced here so it can't be bypassed via the API.
    const viewAll = this.cls.get<boolean>(CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES) === true;
    const term = (query?.search || '').trim();
    if (!viewAll && term.length < 3) {
      const page = query?.page ?? 1;
      const limit = query?.limit ?? 10;
      return { items: [], meta: { total: 0, page, limit, totalPages: 0 } };
    }

    const where: FindOptionsWhere<TripSourceEntity> = {};
    if (filters?.sourceType) where.source_type = filters.sourceType;
    if (filters?.status) where.is_active = filters.status === 'active';
    if (filters?.agentCostId) where.agent_cost_id = filters.agentCostId;

    return this.repo.findPaginated({
      page: query?.page,
      limit: query?.limit,
      search: query?.search,
      searchColumns: ['name', 'short_name', 'contact_name', 'city', 'country'],
      relations: ['agent_cost'],
      order: { name: 'ASC' },
      where,
    });
  }

  async findAllForLookup(search?: string) {
    const term = (search || '').trim();
    // Agent-roster privacy (client policy 2026-10-10): only "view all"
    // roles may browse the full agent list. Everyone else must search
    // (3+ chars) to resolve a specific KNOWN agent — they can never pull
    // the whole roster (which would reveal how many agents exist). This
    // is the real boundary: the Bookings "All Trip Sources" filter + the
    // Query/Booking Source picker both hit this endpoint, so gating here
    // closes the leak regardless of the UI. Mirrors findAll's gate.
    const viewAll = this.cls.get<boolean>(CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES) === true;
    if (!viewAll && term.length < 3) return [];

    const where: FindOptionsWhere<TripSourceEntity> = { is_active: true };
    if (term) {
      // Name match only — single-object WHERE so TenantAwareRepository's
      // auto tenant_id / is_deleted scoping still applies (an array WHERE
      // would break that spread).
      where.name = ILike(`%${term}%`);
    }
    return this.repo.find({
      where,
      select: {
        id: true,
        name: true,
        short_name: true,
        source_type: true,
      },
      order: { name: 'ASC' },
      // Cap when searching OR when the caller is a scoped (non-view-all)
      // role; only a view-all caller with no term gets the full list.
      ...(term || !viewAll ? { take: 30 } : {}),
    });
  }

  /**
   * Find-or-create a trip source by typed name. Used by the Query form
   * so ops can type a brand-new source and have it auto-created on
   * submit. Auto-derives short_name + short_code; contact_name = name.
   * Returns the row (id + name) for the caller to link.
   *
   * Safe for any authed tenant user — no settings permission required.
   */
  async createPlaceholder(name: string) {
    const trimmed = (name || '').trim();
    if (!trimmed) throw new BadRequestException('Source name is required');

    // 1. Exact-name match (case-insensitive via raw query for usability;
    //    typing "acme travel" should match an existing "Acme Travel").
    const existing = await this.repo.findByName(trimmed);
    if (existing) return { id: existing.id, name: existing.name };

    // 2. Derive a unique short_name + short_code via the shared helper.
    //    Both columns share the SAME candidate so they stay aligned;
    //    their unique indexes are both tenant-scoped + is_deleted=false.
    const candidate = await deriveUniqueShortCode(trimmed, 'SRC', async (code) => {
      const [nameHit, codeHit] = await Promise.all([
        this.repo.findByShortName(code),
        this.repo.findByShortCode(code),
      ]);
      return Boolean(nameHit || codeHit);
    });

    try {
      const item = await this.repo.save({
        // source_type accepted values: B2B_AGENT | DIRECT | WEBSITE | REFERRAL
        // (matches CreateTripSourceDto's @IsIn). Placeholder defaults to DIRECT.
        source_type: 'DIRECT',
        short_code: candidate,
        name: trimmed,
        short_name: candidate,
        contact_name: trimmed,
        contact_email: null,
        contact_phone_code: null,
        contact_phone: null,
        city: null,
        state: null,
        country: null,
        pin_code: null,
        street_address: null,
        locality: null,
        landmark: null,
        billing_name: null,
        billing_details: null,
        is_active: true,
      });
      return { id: item.id, name: item.name };
    } catch (err: unknown) {
      // Surface a meaningful message rather than a generic 500. The
      // most common cause is a unique-constraint race (two ops typing
      // the same brand-new name simultaneously); retry once on that.
      const e = err as { message?: string; stack?: string; detail?: string };
      this.logger.error(`createPlaceholder failed for "${trimmed}": ${e?.message}`, e?.stack);
      const msg = e?.detail || e?.message || 'Could not create source';
      throw new BadRequestException(`Failed to create source — ${msg}`);
    }
  }

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

  async create(dto: CreateTripSourceDto) {
    const existing = await this.repo.findByShortName(dto.short_name);
    if (existing) throw new ConflictException(MSG.ALREADY_EXISTS('short name'));
    const media = await this.media.prepare(this.mediaRepo, 'trip-sources', dto.media);

    // short_code is auto-derived; UI no longer collects it. Seeders /
    // imports may still pass one via the DTO.
    const shortCode =
      dto.short_code ||
      (await deriveUniqueShortCode(dto.name, 'SRC', (code) =>
        this.repo.findByShortCode(code).then(Boolean),
      ));

    const agentCostId = await this.resolveAgentCostId(dto.agent_cost_id ?? null);

    const item = await this.repo.save({
      source_type: dto.source_type,
      short_code: shortCode,
      name: dto.name,
      short_name: dto.short_name,
      contact_name: dto.contact_name,
      contact_email: dto.contact_email || null,
      contact_phone_code: dto.contact_phone_code || null,
      contact_phone: dto.contact_phone || null,
      city: dto.city || null,
      state: dto.state || null,
      country: dto.country || null,
      pin_code: dto.pin_code || null,
      street_address: dto.street_address || null,
      locality: dto.locality || null,
      landmark: dto.landmark || null,
      billing_name: dto.billing_name || null,
      billing_details: dto.billing_details || null,
      is_active: dto.is_active ?? true,
      agent_cost_id: agentCostId,
    });
    await this.media.sync(this.mediaRepo, item.id, media);

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

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

    if (dto.short_name && dto.short_name !== item.short_name) {
      const existing = await this.repo.findByShortName(dto.short_name);
      if (existing) throw new ConflictException(MSG.ALREADY_EXISTS('short name'));
    }
    const media = await this.media.prepare(this.mediaRepo, 'trip-sources', dto.media, id);

    // short_code is immutable post-create — strip it from the payload.
    const updateData: Record<string, unknown> = {};
    if (dto.agent_cost_id !== undefined) {
      updateData.agent_cost_id = await this.resolveAgentCostId(dto.agent_cost_id || null);
    }
    const fields = [
      'source_type',
      'name',
      'short_name',
      'contact_name',
      'contact_email',
      'contact_phone_code',
      'contact_phone',
      'city',
      'state',
      'country',
      'pin_code',
      'street_address',
      'locality',
      'landmark',
      'billing_name',
      'billing_details',
      'is_active',
    ] as const;
    const dtoRecord = dto as Record<string, unknown>;
    for (const field of fields) {
      if (dtoRecord[field] !== undefined) updateData[field] = dtoRecord[field];
    }

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

  /** Any source type may use one active rule. A blank value stores null. */
  private async resolveAgentCostId(agentCostId: string | null): Promise<string | null> {
    if (!agentCostId) return null;
    const cost = await this.agentCosts.findActiveById(agentCostId);
    if (!cost) throw new BadRequestException(AGENT_COST_INVALID);
    return cost.id;
  }

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

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