import {
  calendarDefaultsForEventType,
  calendarBlockedWindow,
  type CalendarEventType,
  type CalendarRequest,
  type CalendarRequestStatus,
} from '@wa-ticketing/shared';
import { sql } from '../db.js';
import { auditService } from './auditService.js';
import { contactService } from './contactService.js';
import { ticketService } from './ticketService.js';

export type ClientConfirmStatus = 'pending' | 'confirmed' | 'declined';

export type ClientConfirmState = {
  status: ClientConfirmStatus;
  channel_id?: string;
  sent_at?: string;
  updated_at?: string;
};

function readClientConfirm(extraction: Record<string, unknown> | null | undefined): ClientConfirmState | null {
  const raw = extraction?.client_confirm;
  if (!raw || typeof raw !== 'object') return null;
  const o = raw as Record<string, unknown>;
  const status = o.status;
  if (status !== 'pending' && status !== 'confirmed' && status !== 'declined') return null;
  return {
    status,
    channel_id: typeof o.channel_id === 'string' ? o.channel_id : undefined,
    sent_at: typeof o.sent_at === 'string' ? o.sent_at : undefined,
    updated_at: typeof o.updated_at === 'string' ? o.updated_at : undefined,
  };
}

function mergeExtraction(
  existing: Record<string, unknown> | null | undefined,
  patch: Record<string, unknown>,
): Record<string, unknown> {
  return { ...(existing ?? {}), ...patch };
}

type EnrichedRow = CalendarRequest;

export type CalendarConflict = Pick<
  CalendarRequest,
  'id' | 'summary' | 'starts_at' | 'ends_at' | 'buffer_before_minutes' | 'buffer_after_minutes' | 'event_type'
>;

export type AvailabilityResult = {
  available: boolean;
  conflicts: CalendarConflict[];
};

export type BookOverrides = {
  event_type?: CalendarEventType;
  summary?: string;
  starts_at?: string;
  ends_at?: string;
  buffer_before_minutes?: number;
  buffer_after_minutes?: number;
  assigned_to?: string | null;
};

function assertValidWindow(startsAt: string, endsAt: string): void {
  const s = new Date(startsAt).getTime();
  const e = new Date(endsAt).getTime();
  if (Number.isNaN(s) || Number.isNaN(e) || e <= s) {
    throw Object.assign(new Error('ends_at must be after starts_at'), { statusCode: 400 });
  }
}

export const calendarService = {
  async listRequests(filters: {
    status?: CalendarRequestStatus | CalendarRequestStatus[];
    assignedTo?: string;
    from?: string;
    to?: string;
    ticketId?: string;
    limit?: number;
  }): Promise<EnrichedRow[]> {
    const { assignedTo, from, to, ticketId, limit = 100 } = filters;
    const statuses = filters.status
      ? Array.isArray(filters.status)
        ? filters.status
        : [filters.status]
      : null;

    return sql<EnrichedRow[]>`
      SELECT
        cr.*,
        c.display_name AS contact_display_name,
        c.phone_number AS contact_phone,
        ch.name AS channel_name,
        u.email AS assignee_email,
        u.display_name AS assignee_display_name,
        t.status AS ticket_status
      FROM calendar_requests cr
      JOIN contacts c ON c.id = cr.contact_id
      JOIN channels ch ON ch.id = cr.channel_id
      JOIN tickets t ON t.id = cr.ticket_id
      LEFT JOIN users u ON u.id = cr.assigned_to
      WHERE TRUE
        ${statuses ? sql`AND cr.status = ANY(${statuses}::calendar_request_status[])` : sql``}
        ${assignedTo ? sql`AND cr.assigned_to = ${assignedTo}` : sql``}
        ${ticketId ? sql`AND cr.ticket_id = ${ticketId}` : sql``}
        ${from ? sql`AND cr.starts_at >= ${from}` : sql``}
        ${to ? sql`AND cr.starts_at <= ${to}` : sql``}
      ORDER BY
        CASE WHEN cr.status = 'suggested' THEN 0 ELSE 1 END,
        cr.starts_at ASC
      LIMIT ${limit}
    `;
  },

  async listBookedEvents(opts: {
    from: string;
    to: string;
    assignedTo?: string;
  }): Promise<EnrichedRow[]> {
    const { from, to, assignedTo } = opts;
    return sql<EnrichedRow[]>`
      SELECT
        cr.*,
        c.display_name AS contact_display_name,
        c.phone_number AS contact_phone,
        ch.name AS channel_name,
        u.email AS assignee_email,
        u.display_name AS assignee_display_name,
        t.status AS ticket_status
      FROM calendar_requests cr
      JOIN contacts c ON c.id = cr.contact_id
      JOIN channels ch ON ch.id = cr.channel_id
      JOIN tickets t ON t.id = cr.ticket_id
      LEFT JOIN users u ON u.id = cr.assigned_to
      WHERE cr.status = 'booked'
        AND cr.starts_at < ${to}
        AND cr.ends_at > ${from}
        ${assignedTo ? sql`AND cr.assigned_to = ${assignedTo}` : sql``}
      ORDER BY cr.starts_at ASC
    `;
  },

  async getById(id: string): Promise<EnrichedRow | null> {
    const [row] = await sql<[EnrichedRow?]>`
      SELECT
        cr.*,
        c.display_name AS contact_display_name,
        c.phone_number AS contact_phone,
        ch.name AS channel_name,
        u.email AS assignee_email,
        u.display_name AS assignee_display_name,
        t.status AS ticket_status
      FROM calendar_requests cr
      JOIN contacts c ON c.id = cr.contact_id
      JOIN channels ch ON ch.id = cr.channel_id
      JOIN tickets t ON t.id = cr.ticket_id
      LEFT JOIN users u ON u.id = cr.assigned_to
      WHERE cr.id = ${id}
    `;
    return row ?? null;
  },

  async checkAvailability(opts: {
    assignedTo: string;
    startsAt: string;
    endsAt: string;
    bufferBeforeMinutes: number;
    bufferAfterMinutes: number;
    excludeId?: string;
  }): Promise<AvailabilityResult> {
    assertValidWindow(opts.startsAt, opts.endsAt);
    const { blocked_start, blocked_end } = calendarBlockedWindow({
      starts_at: opts.startsAt,
      ends_at: opts.endsAt,
      buffer_before_minutes: opts.bufferBeforeMinutes,
      buffer_after_minutes: opts.bufferAfterMinutes,
    });

    const conflicts = await sql<CalendarConflict[]>`
      SELECT id, summary, starts_at, ends_at, buffer_before_minutes, buffer_after_minutes, event_type
      FROM calendar_requests
      WHERE status = 'booked'
        AND assigned_to = ${opts.assignedTo}
        ${opts.excludeId ? sql`AND id <> ${opts.excludeId}` : sql``}
        AND (starts_at - (buffer_before_minutes || ' minutes')::interval) < ${blocked_end.toISOString()}
        AND (ends_at + (buffer_after_minutes || ' minutes')::interval) > ${blocked_start.toISOString()}
      ORDER BY starts_at ASC
    `;

    return { available: conflicts.length === 0, conflicts };
  },

  async updateDraft(id: string, patch: BookOverrides): Promise<EnrichedRow> {
    const existing = await this.getById(id);
    if (!existing) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    if (existing.status !== 'suggested') {
      throw Object.assign(new Error('Only suggested requests can be edited'), { statusCode: 400 });
    }

    const startsAt = patch.starts_at ?? existing.starts_at;
    const endsAt = patch.ends_at ?? existing.ends_at;
    assertValidWindow(startsAt, endsAt);

    const eventType = patch.event_type ?? existing.event_type;
    const summary = patch.summary !== undefined ? patch.summary.trim() : existing.summary;
    const bufferBefore =
      patch.buffer_before_minutes !== undefined
        ? Math.max(0, Math.floor(patch.buffer_before_minutes))
        : existing.buffer_before_minutes;
    const bufferAfter =
      patch.buffer_after_minutes !== undefined
        ? Math.max(0, Math.floor(patch.buffer_after_minutes))
        : existing.buffer_after_minutes;
    const assignedTo = patch.assigned_to !== undefined ? patch.assigned_to : existing.assigned_to;

    await sql`
      UPDATE calendar_requests SET
        event_type = ${eventType},
        summary = ${summary},
        starts_at = ${startsAt},
        ends_at = ${endsAt},
        buffer_before_minutes = ${bufferBefore},
        buffer_after_minutes = ${bufferAfter},
        assigned_to = ${assignedTo},
        updated_at = NOW()
      WHERE id = ${id}
    `;

    const updated = await this.getById(id);
    if (!updated) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    return updated;
  },

  async book(id: string, actorId: string, overrides?: BookOverrides): Promise<EnrichedRow> {
    let row = await this.getById(id);
    if (!row) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    if (row.status !== 'suggested') {
      throw Object.assign(new Error('Only suggested requests can be booked'), { statusCode: 400 });
    }

    if (overrides && Object.keys(overrides).length > 0) {
      row = await this.updateDraft(id, overrides);
    }

    const assignedTo = row.assigned_to;
    if (!assignedTo) {
      throw Object.assign(new Error('assigned_to is required to book'), { statusCode: 400 });
    }

    const availability = await this.checkAvailability({
      assignedTo,
      startsAt: row.starts_at,
      endsAt: row.ends_at,
      bufferBeforeMinutes: row.buffer_before_minutes,
      bufferAfterMinutes: row.buffer_after_minutes,
      excludeId: id,
    });

    if (!availability.available) {
      throw Object.assign(
        new Error('Time slot conflicts with an existing booking (including travel buffers)'),
        { statusCode: 409, conflicts: availability.conflicts },
      );
    }

    await sql`
      UPDATE calendar_requests SET
        status = 'booked',
        confirmed_by = ${actorId},
        confirmed_at = NOW(),
        updated_at = NOW()
      WHERE id = ${id} AND status = 'suggested'
    `;

    await auditService.log(actorId, 'user', 'calendar.booked', 'calendar_request', id, {
      ticket_id: row.ticket_id,
      assigned_to: assignedTo,
      starts_at: row.starts_at,
      ends_at: row.ends_at,
    });

    const booked = await this.getById(id);
    if (!booked || booked.status !== 'booked') {
      throw Object.assign(new Error('Booking failed — request may have changed'), { statusCode: 409 });
    }
    return booked;
  },

  async decline(id: string, actorId: string): Promise<EnrichedRow> {
    const row = await this.getById(id);
    if (!row) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    if (row.status !== 'suggested') {
      throw Object.assign(new Error('Only suggested requests can be declined'), { statusCode: 400 });
    }

    await sql`
      UPDATE calendar_requests SET status = 'declined', updated_at = NOW()
      WHERE id = ${id} AND status = 'suggested'
    `;
    await auditService.log(actorId, 'user', 'calendar.declined', 'calendar_request', id, {
      ticket_id: row.ticket_id,
    });

    const updated = await this.getById(id);
    if (!updated) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    return updated;
  },

  async cancel(id: string, actorId: string): Promise<EnrichedRow> {
    const row = await this.getById(id);
    if (!row) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    if (row.status !== 'booked') {
      throw Object.assign(new Error('Only booked events can be cancelled'), { statusCode: 400 });
    }

    await sql`
      UPDATE calendar_requests SET status = 'cancelled', updated_at = NOW()
      WHERE id = ${id} AND status = 'booked'
    `;
    await auditService.log(actorId, 'user', 'calendar.cancelled', 'calendar_request', id, {
      ticket_id: row.ticket_id,
    });

    const updated = await this.getById(id);
    if (!updated) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    return updated;
  },

  async createSuggested(opts: {
    ticketId: string;
    messageId: string | null;
    channelId: string;
    contactId: string;
    eventType: CalendarEventType;
    summary: string;
    startsAt: string;
    endsAt: string;
    assignedTo: string | null;
    extraction: Record<string, unknown>;
    bufferBeforeMinutes?: number;
    bufferAfterMinutes?: number;
  }): Promise<EnrichedRow | null> {
    assertValidWindow(opts.startsAt, opts.endsAt);
    const defaults = calendarDefaultsForEventType(opts.eventType);
    const bufferBefore = opts.bufferBeforeMinutes ?? defaults.bufferBeforeMinutes;
    const bufferAfter = opts.bufferAfterMinutes ?? defaults.bufferAfterMinutes;

    try {
      const [row] = await sql<[CalendarRequest]>`
        INSERT INTO calendar_requests (
          ticket_id, message_id, channel_id, contact_id,
          event_type, summary, starts_at, ends_at,
          buffer_before_minutes, buffer_after_minutes,
          assigned_to, status, extraction
        ) VALUES (
          ${opts.ticketId}, ${opts.messageId}, ${opts.channelId}, ${opts.contactId},
          ${opts.eventType}, ${opts.summary.slice(0, 500)}, ${opts.startsAt}, ${opts.endsAt},
          ${bufferBefore}, ${bufferAfter},
          ${opts.assignedTo}, 'suggested', ${sql.json(opts.extraction as never)}
        )
        RETURNING *
      `;

      await auditService.log(null, 'system', 'calendar.suggested', 'calendar_request', row.id, {
        ticket_id: opts.ticketId,
        message_id: opts.messageId,
        confidence: opts.extraction.confidence ?? null,
      });

      return this.getById(row.id);
    } catch (err: unknown) {
      const msg = String((err as { message?: string })?.message ?? err);
      if (msg.includes('idx_calendar_requests_one_suggested') || msg.includes('unique')) {
        return null;
      }
      throw err;
    }
  },

  /**
   * Staff creates a booking from the day grid (contact + WhatsApp channel + slot).
   * Opens/reuses a direct ticket so confirmation messages stay in-thread.
   */
  async createBooked(opts: {
    contactId?: string;
    phoneNumber?: string;
    channelId: string;
    eventType: CalendarEventType;
    summary: string;
    startsAt: string;
    endsAt: string;
    assignedTo: string;
    actorId: string;
    bufferBeforeMinutes?: number;
    bufferAfterMinutes?: number;
    awaitClientConfirm?: boolean;
  }): Promise<EnrichedRow> {
    assertValidWindow(opts.startsAt, opts.endsAt);
    if (!opts.assignedTo) {
      throw Object.assign(new Error('assigned_to is required'), { statusCode: 400 });
    }

    let contact;
    if (opts.contactId) {
      contact = await contactService.getById(opts.contactId);
      if (!contact) throw Object.assign(new Error('Contact not found'), { statusCode: 404 });
    } else if (opts.phoneNumber?.trim()) {
      if (!contactService.isWhatsAppPhone(opts.phoneNumber)) {
        throw Object.assign(new Error('phone_number must be a WhatsApp number'), { statusCode: 400 });
      }
      contact = await contactService.findOrCreate(opts.phoneNumber.trim());
    } else {
      throw Object.assign(new Error('Provide contact_id or phone_number'), { statusCode: 400 });
    }

    const defaults = calendarDefaultsForEventType(opts.eventType);
    const bufferBefore = opts.bufferBeforeMinutes ?? defaults.bufferBeforeMinutes;
    const bufferAfter = opts.bufferAfterMinutes ?? defaults.bufferAfterMinutes;

    const availability = await this.checkAvailability({
      assignedTo: opts.assignedTo,
      startsAt: opts.startsAt,
      endsAt: opts.endsAt,
      bufferBeforeMinutes: bufferBefore,
      bufferAfterMinutes: bufferAfter,
    });
    if (!availability.available) {
      throw Object.assign(
        new Error('Time slot conflicts with an existing booking (including travel buffers)'),
        { statusCode: 409, conflicts: availability.conflicts },
      );
    }

    let ticket = await ticketService.findOpenTicket(contact.id, opts.channelId);
    if (!ticket) {
      ticket = await ticketService.createTicket(contact.id, opts.channelId, { step: 'done', answers: {} }, {
        assignedTo: opts.assignedTo,
        actorId: opts.actorId,
      });
    }

    const extraction: Record<string, unknown> = { source: 'staff_calendar' };
    if (opts.awaitClientConfirm) {
      extraction.client_confirm = {
        status: 'pending',
        channel_id: opts.channelId,
        updated_at: new Date().toISOString(),
      } satisfies ClientConfirmState;
    }

    const [row] = await sql<[CalendarRequest]>`
      INSERT INTO calendar_requests (
        ticket_id, message_id, channel_id, contact_id,
        event_type, summary, starts_at, ends_at,
        buffer_before_minutes, buffer_after_minutes,
        assigned_to, status, extraction, confirmed_by, confirmed_at
      ) VALUES (
        ${ticket.id}, NULL, ${opts.channelId}, ${contact.id},
        ${opts.eventType}, ${(opts.summary || 'Appointment').slice(0, 500)},
        ${opts.startsAt}, ${opts.endsAt},
        ${bufferBefore}, ${bufferAfter},
        ${opts.assignedTo}, 'booked', ${sql.json(extraction as never)},
        ${opts.actorId}, NOW()
      )
      RETURNING *
    `;

    await auditService.log(opts.actorId, 'user', 'calendar.created_booked', 'calendar_request', row.id, {
      ticket_id: ticket.id,
      assigned_to: opts.assignedTo,
      starts_at: opts.startsAt,
      ends_at: opts.endsAt,
    });

    const created = await this.getById(row.id);
    if (!created) throw Object.assign(new Error('Booking create failed'), { statusCode: 500 });
    return created;
  },

  async setClientConfirm(
    id: string,
    actorId: string,
    status: ClientConfirmStatus,
    channelId?: string,
  ): Promise<EnrichedRow> {
    const row = await this.getById(id);
    if (!row) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    if (row.status !== 'booked') {
      throw Object.assign(new Error('Only booked events support client confirmation'), { statusCode: 400 });
    }

    const prev = readClientConfirm(row.extraction);
    const next: ClientConfirmState = {
      status,
      channel_id: channelId ?? prev?.channel_id ?? row.channel_id,
      sent_at: status === 'pending' ? new Date().toISOString() : prev?.sent_at,
      updated_at: new Date().toISOString(),
    };
    const extraction = mergeExtraction(row.extraction, { client_confirm: next });

    await sql`
      UPDATE calendar_requests
      SET extraction = ${sql.json(extraction as never)}, updated_at = NOW()
      WHERE id = ${id}
    `;
    await auditService.log(actorId, 'user', `calendar.client_confirm.${status}`, 'calendar_request', id, {
      channel_id: next.channel_id,
    });

    const updated = await this.getById(id);
    if (!updated) throw Object.assign(new Error('Calendar request not found'), { statusCode: 404 });
    return updated;
  },

  clientConfirmOf(row: CalendarRequest): ClientConfirmState | null {
    return readClientConfirm(row.extraction);
  },

  async listStaffAssignees(): Promise<Array<{ id: string; email: string; display_name: string | null }>> {
    return sql`
      SELECT id, email, display_name
      FROM users
      WHERE is_active = TRUE AND role IN ('admin', 'user')
      ORDER BY email ASC
    `;
  },

  async hasSuggestedForTicket(ticketId: string): Promise<boolean> {
    const [row] = await sql<[{ exists: boolean }]>`
      SELECT EXISTS(
        SELECT 1 FROM calendar_requests WHERE ticket_id = ${ticketId} AND status = 'suggested'
      ) AS exists
    `;
    return Boolean(row?.exists);
  },
};
