import {
  BadRequestException,
  ForbiddenException,
  Inject,
  Injectable,
  NotFoundException,
  forwardRef,
} from '@nestjs/common';
import {
  AssemblyStatus,
  AssemblyType,
  NotificationChannel,
  ResolutionStatus,
  VoteChoice,
} from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AssembliesGateway } from './assemblies.gateway';
import { PdfService } from '../pdf/pdf.service';
import { AuthenticatedUser } from '../../common/decorators/current-user.decorator';

interface CreateAssemblyInput {
  complexId: string;
  type?: AssemblyType;
  title: string;
  scheduledAt: Date;
  location?: string;
  onlineUrl?: string;
  quorumPct?: number;
  agendaItems: { title: string; description?: string; requiresVote?: boolean; resolutionText?: string; majority?: string }[];
}

@Injectable()
export class AssembliesService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly notifications: NotificationsService,
    @Inject(forwardRef(() => AssembliesGateway))
    private readonly gateway: AssembliesGateway,
    private readonly pdf: PdfService,
  ) {}

  async create(user: AuthenticatedUser, dto: CreateAssemblyInput) {
    if (!user.tenantId) throw new BadRequestException('Tenant required');
    const complex = await this.prisma.complex.findFirst({
      where: { id: dto.complexId, tenantId: user.tenantId },
    });
    if (!complex) throw new NotFoundException('Complex not found');

    return this.prisma.$transaction(async (tx) => {
      const assembly = await tx.generalAssembly.create({
        data: {
          complexId: dto.complexId,
          type: dto.type ?? AssemblyType.ORDINAIRE,
          title: dto.title,
          scheduledAt: dto.scheduledAt,
          location: dto.location,
          onlineUrl: dto.onlineUrl,
          quorumPct: dto.quorumPct ?? 50,
        },
      });

      for (let i = 0; i < dto.agendaItems.length; i++) {
        const a = dto.agendaItems[i];
        const agenda = await tx.agendaItem.create({
          data: {
            assemblyId: assembly.id,
            order: i + 1,
            title: a.title,
            description: a.description,
            requiresVote: a.requiresVote ?? true,
          },
        });
        if (a.requiresVote !== false) {
          await tx.resolution.create({
            data: {
              agendaItemId: agenda.id,
              text: a.resolutionText ?? a.title,
              majority: a.majority ?? 'SIMPLE',
            },
          });
        }
      }
      return tx.generalAssembly.findUnique({
        where: { id: assembly.id },
        include: { agendaItems: { include: { resolutions: true } } },
      });
    });
  }

  list(user: AuthenticatedUser, complexId?: string) {
    if (!user.tenantId && !user.isSuperAdmin) return [];
    return this.prisma.generalAssembly.findMany({
      where: {
        ...(complexId && { complexId }),
        ...(user.tenantId && { complex: { tenantId: user.tenantId } }),
      },
      orderBy: { scheduledAt: 'desc' },
      include: {
        _count: { select: { agendaItems: true, convocations: true } },
      },
    });
  }

  async findOne(user: AuthenticatedUser, id: string) {
    const assembly = await this.prisma.generalAssembly.findUnique({
      where: { id },
      include: {
        complex: true,
        agendaItems: {
          orderBy: { order: 'asc' },
          include: { resolutions: { include: { _count: { select: { ballots: true } } } } },
        },
        convocations: { include: { user: { select: { id: true, email: true, firstName: true } } } },
        proxies: true,
      },
    });
    if (!assembly) throw new NotFoundException('Assembly not found');
    if (!user.isSuperAdmin && assembly.complex.tenantId !== user.tenantId) {
      throw new ForbiddenException();
    }
    return assembly;
  }

  /**
   * Issue convocations to every coproprietaire of the complex.
   * Creates Convocation rows and sends notification via email.
   */
  async sendConvocations(user: AuthenticatedUser, id: string) {
    const assembly = await this.findOne(user, id);
    if (assembly.status !== AssemblyStatus.DRAFT) {
      throw new BadRequestException('Assembly already convocated');
    }

    const copropriotaires = await this.prisma.resident.findMany({
      where: {
        lot: { complexId: assembly.complexId },
        role: 'PROPRIETAIRE',
        endDate: null,
      },
      include: { user: true },
      distinct: ['userId'],
    });

    for (const r of copropriotaires) {
      const conv = await this.prisma.convocation.upsert({
        where: { assemblyId_userId: { assemblyId: id, userId: r.user.id } },
        create: { assemblyId: id, userId: r.user.id },
        update: {},
      });
      await this.notifications.send({
        tenantId: assembly.complex.tenantId,
        userId: r.user.id,
        channel: NotificationChannel.EMAIL,
        template: 'assembly_convocation',
        recipient: r.user.email,
        subject: `Convocation AG — ${assembly.title}`,
        body: `Vous êtes convoqué(e) à l'AG du ${assembly.scheduledAt.toISOString()} à ${assembly.location ?? 'en ligne'}.`,
        metadata: { assemblyId: id, convocationId: conv.id },
      });
      await this.prisma.convocation.update({
        where: { id: conv.id },
        data: { sentAt: new Date() },
      });
    }

    return this.prisma.generalAssembly.update({
      where: { id },
      data: {
        status: AssemblyStatus.CONVOCATED,
        // P0-2 — Ouverture du vote par correspondance 7 jours avant la séance.
        correspondenceOpenAt: new Date(assembly.scheduledAt.getTime() - 7 * 24 * 60 * 60 * 1000),
      },
    });
  }

  async open(user: AuthenticatedUser, id: string) {
    const a = await this.findOne(user, id);
    if (a.status !== AssemblyStatus.CONVOCATED) {
      throw new BadRequestException('Assembly must be CONVOCATED');
    }
    return this.prisma.generalAssembly.update({
      where: { id },
      data: { status: AssemblyStatus.IN_PROGRESS },
    });
  }

  async close(user: AuthenticatedUser, id: string) {
    const a = await this.findOne(user, id);
    if ((a.status as AssemblyStatus) !== AssemblyStatus.IN_PROGRESS) {
      throw new BadRequestException('Assembly not in progress');
    }
    // Close all resolutions and compute status
    for (const item of a.agendaItems) {
      for (const r of item.resolutions) {
        if (r.status !== ResolutionStatus.PENDING) continue;
        const totalCast = r.totalFor + r.totalAgainst + r.totalAbstain;
        const denom = r.majority === 'ABSOLUTE' ? totalCast + 0 : r.totalFor + r.totalAgainst; // abstain excluded for SIMPLE
        const pct = denom > 0 ? (r.totalFor / denom) * 100 : 0;
        const required = r.majority === 'ABSOLUTE' ? 50 : r.majority === 'DOUBLE' ? 66.67 : 50;
        await this.prisma.resolution.update({
          where: { id: r.id },
          data: {
            status: pct > required ? ResolutionStatus.ADOPTED : ResolutionStatus.REJECTED,
            closedAt: new Date(),
          },
        });
      }
    }
    return this.prisma.generalAssembly.update({
      where: { id },
      data: { status: AssemblyStatus.CLOSED },
    });
  }

  /** Generate a PV (procès-verbal) PDF for a closed assembly. */
  async generatePvPdf(user: AuthenticatedUser, id: string): Promise<Buffer | null> {
    const a = await this.findOne(user, id);
    if (a.status !== AssemblyStatus.CLOSED) return null;
    const resolutions: {
      title: string; text: string; majority: string; status: string;
      votesFor: number; votesAgainst: number; votesAbstain: number;
    }[] = [];
    for (const item of a.agendaItems) {
      for (const r of item.resolutions) {
        resolutions.push({
          title: item.title,
          text: r.text,
          majority: r.majority,
          status: r.status,
          votesFor: Number(r.totalFor),
          votesAgainst: Number(r.totalAgainst),
          votesAbstain: Number(r.totalAbstain),
        });
      }
    }
    return this.pdf.generatePvAssembly({
      assemblyTitle: a.title,
      heldAt: a.updatedAt,
      location: a.location ?? undefined,
      complexName: a.complex.name,
      resolutions,
    });
  }

  // ---------- Proxies ----------

  async giveProxy(user: AuthenticatedUser, assemblyId: string, receiverId: string) {
    const a = await this.findOne(user, assemblyId);
    const allowedStatuses: AssemblyStatus[] = [AssemblyStatus.CONVOCATED, AssemblyStatus.IN_PROGRESS];
    if (!allowedStatuses.includes(a.status)) {
      throw new BadRequestException('Proxies only allowed after convocation');
    }
    if (receiverId === user.userId) throw new BadRequestException('Cannot give proxy to yourself');

    const weight = await this.computeVoteWeight(user.userId, a.complexId);
    if (weight <= 0) throw new BadRequestException('You have no voting weight in this complex');

    return this.prisma.proxy.upsert({
      where: { assemblyId_giverId: { assemblyId, giverId: user.userId } },
      create: { assemblyId, giverId: user.userId, receiverId, weight },
      update: { receiverId, weight, revokedAt: null },
    });
  }

  // ---------- Voting ----------

  async vote(
    user: AuthenticatedUser,
    resolutionId: string,
    choice: VoteChoice,
  ) {
    const resolution = await this.prisma.resolution.findUnique({
      where: { id: resolutionId },
      include: { agendaItem: { include: { assembly: { include: { complex: true } } } } },
    });
    if (!resolution) throw new NotFoundException('Resolution not found');
    const assembly = resolution.agendaItem.assembly;
    // P0-2 — Vote autorisé soit en séance (IN_PROGRESS), soit pendant la
    // fenêtre de correspondance (CONVOCATED, entre correspondenceOpenAt et scheduledAt).
    const now = new Date();
    const inSession = assembly.status === AssemblyStatus.IN_PROGRESS;
    const inCorrespondence =
      assembly.status === AssemblyStatus.CONVOCATED &&
      !!assembly.correspondenceOpenAt &&
      assembly.correspondenceOpenAt.getTime() <= now.getTime() &&
      assembly.scheduledAt.getTime() > now.getTime();
    if (!inSession && !inCorrespondence) {
      throw new BadRequestException('Assembly is not open for voting');
    }
    const byCorrespondence = inCorrespondence;
    if (!user.isSuperAdmin && assembly.complex.tenantId !== user.tenantId) {
      throw new ForbiddenException();
    }

    let weight = await this.computeVoteWeight(user.userId, assembly.complexId);
    // Add weight from proxies received (non-revoked)
    const proxies = await this.prisma.proxy.findMany({
      where: { assemblyId: assembly.id, receiverId: user.userId, revokedAt: null },
    });
    weight += proxies.reduce((s, p) => s + p.weight, 0);

    if (weight <= 0) throw new BadRequestException('No voting weight');

    // If user gave their own voting rights via proxy, they cannot vote directly
    const myProxy = await this.prisma.proxy.findUnique({
      where: { assemblyId_giverId: { assemblyId: assembly.id, giverId: user.userId } },
    });
    if (myProxy && !myProxy.revokedAt && myProxy.receiverId !== user.userId) {
      weight -= myProxy.weight;
    }

    const ballot = await this.prisma.$transaction(async (tx) => {
      const existing = await tx.voteBallot.findUnique({
        where: { resolutionId_userId: { resolutionId, userId: user.userId } },
      });
      if (existing) {
        // Revert old tally before applying new
        await tx.resolution.update({
          where: { id: resolutionId },
          data: this.tallyDelta(existing.choice, -existing.weight),
        });
      }
      const ballot = await tx.voteBallot.upsert({
        where: { resolutionId_userId: { resolutionId, userId: user.userId } },
        create: { resolutionId, userId: user.userId, choice, weight, byCorrespondence },
        update: { choice, weight, castAt: new Date(), byCorrespondence },
      });
      await tx.resolution.update({
        where: { id: resolutionId },
        data: this.tallyDelta(choice, weight),
      });
      return ballot;
    });

    // Broadcast aggregate totals via WebSocket
    const updated = await this.prisma.resolution.findUnique({ where: { id: resolutionId } });
    if (updated) {
      this.gateway.broadcastVoteCast(assembly.id, {
        resolutionId,
        choice,
        totals: {
          for: updated.totalFor,
          against: updated.totalAgainst,
          abstain: updated.totalAbstain,
        },
      });
    }
    return ballot;
  }

  private tallyDelta(choice: VoteChoice, weight: number) {
    if (choice === VoteChoice.FOR) return { totalFor: { increment: weight } };
    if (choice === VoteChoice.AGAINST) return { totalAgainst: { increment: weight } };
    return { totalAbstain: { increment: weight } };
  }

  /** Sum of tantiemesGeneral across all lots where the user is a PROPRIETAIRE. */
  private async computeVoteWeight(userId: string, complexId: string): Promise<number> {
    const residences = await this.prisma.resident.findMany({
      where: { userId, role: 'PROPRIETAIRE', endDate: null, lot: { complexId } },
      include: { lot: true },
    });
    return residences.reduce((s, r) => s + r.lot.tantiemesGeneral, 0);
  }

  async myWeight(user: AuthenticatedUser, assemblyId: string) {
    const a = await this.findOne(user, assemblyId);
    const ownWeight = await this.computeVoteWeight(user.userId, a.complexId);
    const received = await this.prisma.proxy.findMany({
      where: { assemblyId, receiverId: user.userId, revokedAt: null },
    });
    return {
      ownWeight,
      proxyWeight: received.reduce((s, p) => s + p.weight, 0),
      totalWeight: ownWeight + received.reduce((s, p) => s + p.weight, 0),
    };
  }
}
