import {
  HttpStatus,
  Injectable,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import {
  appointmentWithinWorkingHours,
  blocksForDay,
  localDayOfWeekMondayZero,
} from '../../common/working-hours';
import { civilDateJsWeekday, isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { AppException, ErrorCode } from '../../common/errors';

const MS_PER_DAY = 86_400_000;

@Injectable()
export class AppointmentsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly staffWorkingHoursService: StaffWorkingHoursService,
    private readonly treatmentCatalog: TreatmentCatalogService,
  ) {}

  getOrganizationIdFromUser(user: { organizationId?: string }) {
    if (!user?.organizationId) {
      throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
    }
    return user.organizationId;
  }

  async listColumnProviders(organizationId: string, actorUserId: string, date?: string) {
    await this.assertCanViewAppointments(actorUserId, organizationId);

    const members = await this.prisma.membership.findMany({
      where: {
        organizationId,
        OR: [{ isOwner: true }, { isActive: true }],
        permissions: {
          some: {
            permission: {
              name: 'TAB_TREATMENT_EDIT',
            },
          },
        },
      },
      include: {
        user: { select: { id: true, name: true } },
      },
      orderBy: [{ createdAt: 'asc' }],
    });

    const scheduleBlocksByMembership =
      await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds(
        members.map((m) => m.id),
      );

    const dayOfWeek = this.resolveDayOfWeekMondayZero(date);

    const data = members.map((m) => {
      const allBlocks = scheduleBlocksByMembership.get(m.id) ?? [];
      const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
      return {
        userId: m.user.id,
        name: m.user.name,
        hasWorkingHours: allBlocks.length > 0,
        dayBlocks,
      };
    });

    return { success: true, data };
  }

  async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
    const { scopeToProvider } = await this.assertCanListAppointmentsForTreatment(
      actorUserId,
      organizationId,
    );

    const from = new Date(query.from);
    const to = new Date(query.to);

    if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
      throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE, HttpStatus.BAD_REQUEST);
    }

    if (to <= from) {
      throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
    }

    const items = await this.prisma.appointment.findMany({
      where: {
        organizationId,
        startAt: { lt: to },
        endAt: { gt: from },
        ...(scopeToProvider ? { providerUserId: actorUserId } : {}),
      },
      include: {
        patient: {
          select: { id: true, firstName: true, lastName: true, mobile: true },
        },
        treatment: { select: { id: true } },
      },
      orderBy: [{ startAt: 'asc' }],
    });

    return {
      success: true,
      data: items.map(({ treatment, ...appointment }) => ({
        ...appointment,
        hasTreatment: Boolean(treatment),
      })),
    };
  }

  async create(
    dto: CreateAppointmentDto,
    organizationId: string,
    actorUserId: string,
  ) {
    await this.assertCanEditAppointments(actorUserId, organizationId);

    const startAt = new Date(dto.startAt);
    const endAt = new Date(dto.endAt);
    const timeZone = this.requireTimeZone(dto.timeZone);

    if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
      throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
    }

    if (endAt <= startAt) {
      throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
    }

    if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
      throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
    }

    const now = Date.now();
    if (startAt.getTime() < now) {
      throw new AppException(ErrorCode.APPOINTMENT_IN_PAST, HttpStatus.BAD_REQUEST);
    }

    await this.ensurePatientInOrg(dto.patientId, organizationId);
    await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
    this.treatmentCatalog.assertKnownTreatmentType(dto.purpose);
    await this.ensureAppointmentWithinProviderWorkingHours(
      dto.providerUserId,
      organizationId,
      startAt,
      endAt,
      timeZone,
    );

    const appointment = await this.prisma.appointment.create({
      data: {
        organizationId,
        patientId: dto.patientId,
        providerUserId: dto.providerUserId,
        startAt,
        endAt,
        purpose: dto.purpose,
      },
      include: {
        patient: {
          select: { id: true, firstName: true, lastName: true, mobile: true },
        },
      },
    });

    return { success: true, data: appointment };
  }

  async update(
    id: string,
    dto: UpdateAppointmentDto,
    organizationId: string,
    actorUserId: string,
  ) {
    await this.assertCanEditAppointments(actorUserId, organizationId);

    const existing = await this.prisma.appointment.findFirst({
      where: { id, organizationId },
    });

    if (!existing) {
      throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

    const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt;
    const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt;
    const timeZone = this.requireTimeZone(dto.timeZone);

    if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
      throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
    }

    if (endAt <= startAt) {
      throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
    }

    if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
      throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
    }

    const patientId = dto.patientId ?? existing.patientId;
    const providerUserId = dto.providerUserId ?? existing.providerUserId;
    const purpose = dto.purpose ?? existing.purpose;

    if (dto.patientId && dto.patientId !== existing.patientId) {
      const linkedTreatment = await this.prisma.treatment.findUnique({
        where: { appointmentId: id },
        select: { id: true },
      });
      if (linkedTreatment) {
        throw new AppException(ErrorCode.APPOINTMENT_PATIENT_LOCKED, HttpStatus.CONFLICT);
      }
    }

    this.treatmentCatalog.assertKnownTreatmentType(purpose);

    await this.ensurePatientInOrg(patientId, organizationId);
    await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
    await this.ensureAppointmentWithinProviderWorkingHours(
      providerUserId,
      organizationId,
      startAt,
      endAt,
      timeZone,
    );

    const appointment = await this.prisma.appointment.update({
      where: { id },
      data: {
        patientId,
        providerUserId,
        startAt,
        endAt,
        purpose,
      },
      include: {
        patient: {
          select: { id: true, firstName: true, lastName: true, mobile: true },
        },
      },
    });

    return { success: true, data: appointment };
  }

  async remove(id: string, organizationId: string, actorUserId: string) {
    await this.assertCanEditAppointments(actorUserId, organizationId);

    const existing = await this.prisma.appointment.findFirst({
      where: { id, organizationId },
      select: { id: true, treatment: { select: { id: true } } },
    });

    if (!existing) {
      throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

    if (existing.treatment) {
      throw new AppException(ErrorCode.APPOINTMENT_HAS_TREATMENT, HttpStatus.CONFLICT);
    }

    await this.prisma.appointment.delete({
      where: { id },
    });

    return { success: true };
  }

  private async assertCanViewAppointments(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    if (m.isOwner) {
      return;
    }
    const names = m.permissions.map((p) => p.permission.name);
    if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
      return;
    }
    throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
  }

  private async assertCanListAppointmentsForTreatment(
    userId: string,
    organizationId: string,
  ) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    if (m.isOwner) {
      return { membership: m, scopeToProvider: false as const };
    }
    const names = m.permissions.map((p) => p.permission.name);
    const canViewSchedule =
      names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT');
    const canViewTreatment =
      names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT');
    if (!canViewSchedule && !canViewTreatment) {
      throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
    }
    return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
  }

  private async assertCanEditAppointments(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    if (m.isOwner) {
      return;
    }
    const names = m.permissions.map((p) => p.permission.name);
    if (names.includes('TAB_APPOINTMENTS_EDIT')) {
      return;
    }
    throw new AppException(ErrorCode.PERMISSION_EDIT_APPOINTMENTS, HttpStatus.FORBIDDEN);
  }

  private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
    const m = await this.getMembership(providerUserId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
    }
    if (!m.isOwner && !m.isActive) {
      throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_INACTIVE, HttpStatus.BAD_REQUEST);
    }
    if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
      throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT, HttpStatus.BAD_REQUEST);
    }
  }

  private async ensurePatientInOrg(patientId: string, organizationId: string) {
    const patient = await this.prisma.patient.findFirst({
      where: {
        id: patientId,
        isWalkIn: false,
        createdByOrganizationId: organizationId,
      },
      select: { id: true },
    });
    if (!patient) {
      throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
    }
  }

  private requireTimeZone(timeZone: string | undefined): string {
    if (!timeZone || !isValidIanaTimeZone(timeZone)) {
      throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST);
    }
    return timeZone;
  }

  private resolveDayOfWeekMondayZero(date?: string): number {
    if (!date) {
      return localDayOfWeekMondayZero(new Date().getUTCDay());
    }
    if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
      throw new AppException(ErrorCode.APPOINTMENT_INVALID_DATE, HttpStatus.BAD_REQUEST);
    }
    return localDayOfWeekMondayZero(civilDateJsWeekday(date));
  }

  private async ensureAppointmentWithinProviderWorkingHours(
    providerUserId: string,
    organizationId: string,
    startAt: Date,
    endAt: Date,
    timeZone: string,
  ) {
    const membership = await this.getMembership(providerUserId, organizationId);
    if (!membership) {
      throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
    }

    const scheduleBlocksByMembership =
      await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]);
    const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? [];
    if (allBlocks.length === 0) {
      throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_WORKING_HOURS, HttpStatus.BAD_REQUEST);
    }

    const dayOfWeek = localDayOfWeekMondayZero(zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday);
    const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
    if (dayBlocks.length === 0) {
      throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_WORKING_DAY, HttpStatus.BAD_REQUEST);
    }

    if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone)) {
      throw new AppException(ErrorCode.APPOINTMENT_OUTSIDE_WORKING_HOURS, HttpStatus.BAD_REQUEST);
    }
  }

  private async getMembership(userId: string, organizationId: string) {
    return this.prisma.membership.findFirst({
      where: {
        userId,
        organizationId,
        OR: [{ isOwner: true }, { isActive: true }],
      },
      include: {
        permissions: { include: { permission: true } },
        organization: { include: { type: true, plan: true } },
      },
    });
  }
}
