import {
  NotificationAudience,
  NotificationType,
} from '@edr/types';
import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import {
  PriorityRuleChangeRequest,
  PriorityRuleChangeStatus,
} from '../entities/priority-rule-change-request.entity';
import { PriorityConfigsService } from './priority-configs.service';

/** Backoffice rule-engine page — where both queue and rules live. */
const RULES_LINK = '/dashboard/rules/priority-configs';

/**
 * Approval workflow for priority-rule changes. Nobody mutates priority configs
 * directly any more: a change is SUBMITTED here (validated up front so the
 * requester gets immediate feedback on range collisions), the team is
 * notified, and an approver later applies or rejects it. Applying re-runs the
 * full validation — the winning state is whatever is true at approval time.
 */
@Injectable()
export class PriorityRuleChangeRequestsService {
  private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);

  constructor(
    @InjectRepository(PriorityRuleChangeRequest)
    private readonly repo: Repository<PriorityRuleChangeRequest>,
    private readonly configs: PriorityConfigsService,
    private readonly inbox: NotificationInboxService,
  ) {}

  async submit(
    dto: SubmitPriorityRuleChangeDto,
    userId?: string | null,
  ): Promise<PriorityRuleChangeRequest> {
    const payload = await this.validateSubmission(dto);

    const request = await this.repo.save(
      this.repo.create({
        action: dto.action,
        priorityConfigId: dto.priorityConfigId ?? null,
        payload,
        status: 'PENDING',
        requestedByUserId: userId ?? null,
      }),
    );

    this.notifyTeam(
      'Priority rule change submitted',
      `A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
      request,
    );
    return request;
  }

  async list(status?: PriorityRuleChangeStatus): Promise<PriorityRuleChangeRequest[]> {
    return this.repo.find({
      where: status ? { status } : {},
      relations: { priorityConfig: true },
      order: { createdAt: 'DESC' },
    });
  }

  async approve(
    id: string,
    userId?: string | null,
    decisionNote?: string,
    canSelfApprove = false,
  ): Promise<PriorityRuleChangeRequest> {
    const request = await this.findPending(id);

    // Separation of duties: the requester cannot approve their own change —
    // except super admins, who have full backoffice authority.
    // TODO: split approval into a distinct approver permission rather than
    // relying on this id check.
    if (!canSelfApprove && userId && userId === request.requestedByUserId) {
      throw new ForbiddenException(
        'You cannot approve a change request you submitted',
      );
    }

    // Apply the change through the normal service so currency + range-collision
    // validation runs against the CURRENT rules; a stale request that now
    // collides fails here and stays PENDING for the approver to see the error.
    if (request.action === 'CREATE') {
      await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
    } else if (request.action === 'UPDATE') {
      await this.configs.update(
        this.requireTarget(request),
        request.payload as unknown as UpdatePriorityConfigDto,
      );
    } else {
      await this.configs.remove(this.requireTarget(request));
    }

    request.status = 'APPROVED';
    request.decidedByUserId = userId ?? null;
    request.decidedAt = new Date();
    request.decisionNote = decisionNote ?? null;
    const saved = await this.repo.save(request);

    this.notifyTeam(
      'Priority rule change approved',
      `The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
        (decisionNote ? ` Note: ${decisionNote}` : ''),
      saved,
    );
    return saved;
  }

  async reject(
    id: string,
    userId?: string | null,
    decisionNote?: string,
  ): Promise<PriorityRuleChangeRequest> {
    const request = await this.findPending(id);
    request.status = 'REJECTED';
    request.decidedByUserId = userId ?? null;
    request.decidedAt = new Date();
    request.decisionNote = decisionNote ?? null;
    const saved = await this.repo.save(request);

    this.notifyTeam(
      'Priority rule change rejected',
      `The ${request.action.toLowerCase()} priority-rule change was rejected.` +
        (decisionNote ? ` Note: ${decisionNote}` : ''),
      saved,
    );
    return saved;
  }

  /**
   * Validate a submission the way applying it would, so bad requests are
   * refused at the door — most importantly the wagon-range collision rule.
   * Returns the payload to persist.
   */
  private async validateSubmission(
    dto: SubmitPriorityRuleChangeDto,
  ): Promise<Record<string, unknown> | null> {
    if (dto.action === 'CREATE') {
      if (!dto.create) {
        throw new BadRequestException('CREATE requires the proposed rule in `create`');
      }
      await this.configs.assertNoRangeCollision({
        type: dto.create.type,
        currency: dto.create.currency ?? null,
        minWagonCount: dto.create.minWagonCount,
        maxWagonCount: dto.create.maxWagonCount,
      });
      return { ...dto.create };
    }

    if (!dto.priorityConfigId) {
      throw new BadRequestException(`${dto.action} requires priorityConfigId`);
    }
    const existing = await this.configs.findById(dto.priorityConfigId);

    if (dto.action === 'DELETE') return null;

    if (!dto.update || Object.keys(dto.update).length === 0) {
      throw new BadRequestException('UPDATE requires the field changes in `update`');
    }
    await this.configs.assertNoRangeCollision({
      type: dto.update.type ?? existing.type,
      currency:
        dto.update.currency !== undefined ? dto.update.currency : existing.currency,
      minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
      maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
      excludeId: existing.id,
    });
    return { ...dto.update };
  }

  private async findPending(id: string): Promise<PriorityRuleChangeRequest> {
    const request = await this.repo.findOne({
      where: { id },
      relations: { priorityConfig: true },
    });
    if (!request) throw new NotFoundException(`Change request ${id} not found`);
    if (request.status !== 'PENDING') {
      throw new ConflictException(
        `Change request is already ${request.status.toLowerCase()}`,
      );
    }
    return request;
  }

  private requireTarget(request: PriorityRuleChangeRequest): string {
    if (!request.priorityConfigId) {
      throw new BadRequestException(
        `${request.action} change request has no target rule`,
      );
    }
    return request.priorityConfigId;
  }

  /**
   * In-app notification to the whole backoffice team (submission AND decision
   * both notify the team; the requester is staff, so they are included).
   * Fire-and-forget — a notification failure never blocks the workflow.
   */
  private notifyTeam(
    title: string,
    body: string,
    request: PriorityRuleChangeRequest,
  ): void {
    void this.inbox
      .notify({
        recipients: { allBackoffice: true },
        audience: NotificationAudience.BACKOFFICE,
        type: NotificationType.REQUEST_SUBMITTED,
        title,
        body,
        link: RULES_LINK,
        data: { priorityRuleChangeRequestId: request.id, action: request.action },
      })
      .catch((err) =>
        this.logger.warn(
          `Priority-rule notification failed: ${(err as Error).message}`,
        ),
      );
  }
}
