import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';

import {
  IssuesRepository,
  type SubmittedIssueResolve,
} from './issues.repository';
import { QueryIssuesDto } from './dto/query-issues.dto';
import { UpdateIssueDto } from './dto/update-issue.dto';
import { CaslAbilityFactory } from '@/casl/casl-ability.factory';
import { SystemNotificationsService } from '@/modules/system-notifications/system-notifications.service';
import { WorkingGroupIssuesService } from '@/modules/working-group-issues/working-group-issues.service';

// The two roles this module serves. Each sees only its own escalations and the
// issues its own users created.
export const ISSUE_MATRIX_TARGETS = {
  cdc: { escalateName: 'CDC', roleName: 'cdc' },
  cefp: { escalateName: 'CEFP', roleName: 'cefp' },
} as const;

export type IssueMatrixTarget =
  (typeof ISSUE_MATRIX_TARGETS)[keyof typeof ISSUE_MATRIX_TARGETS];

const SOLVED_STATUS_CODE = 'SOLVED';
const IN_PROGRESS_STATUS_CODE = 'IN_PROGRESS';
const NOT_ADDRESSED_STATUS_CODE = 'NOT_ADDRESSED';

// OWN: raised by this matrix's own role. MINISTRY: escalated by a ministry.
export type IssueMatrixSource = 'OWN' | 'MINISTRY';

type IssueMatrixStatus = {
  id: number;
  code: string;
  name: string;
};

// One government agency on an issue. `agencyOrder` 1 is the primary ministry.
export type IssueMatrixAgency = {
  id: number;
  name: string;
  logo: string | null;
  agencyOrder: number;
};

// The agency rows as they come back from Prisma, before being flattened.
type IssueGovernmentAgency = {
  agencyOrder: number;
  stakeholder: {
    id: number;
    name: string;
    logo: string | null;
  };
};

export type IssueMatrixItem = {
  issueId: number;
  issueResolveId: number | null;
  meetingSummaryId: number | null;
  source: IssueMatrixSource;
  title: string;
  description: string;
  recommendation: string;
  category: {
    id: number;
    name: string;
  };
  status: IssueMatrixStatus;
  workingGroup: {
    id: number;
    name: string;
  };
  primaryAgency: {
    id: number;
    name: string;
    logo: string | null;
  } | null;
  // Every ministry on the issue, ordered. The first entry is the primary one.
  governmentAgencies: IssueMatrixAgency[];
  rgcDecision: unknown;
  nextStep: unknown;
  remark: unknown;
  // Flagged for the plenary. Null means nobody has decided yet.
  escalation: boolean | null;
  submittedAt: Date;
};

export type PaginatedIssueMatrix = {
  data: IssueMatrixItem[];
  meta: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
};

/** What comes back when only the plenary flag was changed. */
export type EscalationOnlyResult = {
  issueId: number;
  escalation: boolean;
};

export type IssueMatrixSummary = {
  totalIssues: number;
  solved: number;
  inProgress: number;
  notAddressed: number;
  totalPrimaryAgencies: number;
};

@Injectable()
export class IssuesService {
  constructor(
    private readonly issueMatrixRepository: IssuesRepository,
    private readonly workingGroupIssuesService: WorkingGroupIssuesService,
    private readonly caslAbilityFactory: CaslAbilityFactory,
    private readonly notifications: SystemNotificationsService,
  ) {}

  /**
   * Work out which matrix the caller is looking at from the roles they hold.
   * CEFP users get the CEFP matrix; everyone else gets the CDC one.
   */
  async resolveTarget(userId: number): Promise<IssueMatrixTarget> {
    const { roles } = await this.caslAbilityFactory.getGrantsForUser(userId);
    const hasCefpRole = roles.some(
      (role) =>
        role.trim().toLowerCase() === ISSUE_MATRIX_TARGETS.cefp.roleName,
    );

    return hasCefpRole ? ISSUE_MATRIX_TARGETS.cefp : ISSUE_MATRIX_TARGETS.cdc;
  }

  async findAll(
    query: QueryIssuesDto,
    target: IssueMatrixTarget,
  ): Promise<PaginatedIssueMatrix> {
    const page = query.page ?? 1;
    const limit = query.limit ?? 10;
    const items = await this.getMatrixItems(target);
    const total = items.length;
    const startIndex = (page - 1) * limit;

    return {
      data: items.slice(startIndex, startIndex + limit),
      meta: {
        page,
        limit,
        total,
        totalPages: total === 0 ? 0 : Math.ceil(total / limit),
      },
    };
  }

  async getSummary(target: IssueMatrixTarget): Promise<IssueMatrixSummary> {
    const items = await this.getMatrixItems(target);
    const primaryAgencyIds = new Set<number>();
    let solved = 0;
    let inProgress = 0;
    let notAddressed = 0;

    for (const item of items) {
      const statusCode = item.status.code;

      if (statusCode === SOLVED_STATUS_CODE) solved += 1;
      if (statusCode === IN_PROGRESS_STATUS_CODE) inProgress += 1;
      if (statusCode === NOT_ADDRESSED_STATUS_CODE) notAddressed += 1;
      if (item.primaryAgency) primaryAgencyIds.add(item.primaryAgency.id);
    }

    return {
      totalIssues: items.length,
      solved,
      inProgress,
      notAddressed,
      totalPrimaryAgencies: primaryAgencyIds.size,
    };
  }

  async findIssueForDetail(issueId: number, target: IssueMatrixTarget) {
    const items = await this.getMatrixItems(target);

    if (!items.some((item) => item.issueId === issueId)) {
      throw new NotFoundException(
        `Issue ${issueId} was not found in the CDC issue matrix.`,
      );
    }

    return this.workingGroupIssuesService.findOneForIssueMatrixDetail(issueId);
  }

  /**
   * Change the government agency and/or the status of one matrix row, straight
   * from the table. Only CDC-created issues may change their agency. Ministry-
   * submitted issues may still change status here.
   */
  /**
   * Update an issue from either editor.
   *
   * The table sends only the field it changed (ministry, status or the plenary
   * flag). The edit form sends the whole issue, and possibly an attachment;
   * that path is limited to issues this role raised itself.
   */
  async updateIssue(
    issueId: number,
    dto: UpdateIssueDto,
    target: IssueMatrixTarget,
    attachmentFile?: Express.Multer.File,
    userId?: number,
  ) {
    const isFormEdit =
      attachmentFile !== undefined ||
      dto.title !== undefined ||
      dto.description !== undefined ||
      dto.recommendation !== undefined ||
      dto.categoryId !== undefined ||
      dto.governmentAgencies !== undefined;

    if (isFormEdit) {
      return this.workingGroupIssuesService.updateIssueCreatedByRole(
        issueId,
        target.roleName,
        dto,
        attachmentFile,
        userId,
      );
    }

    return this.updateMatrixRow(issueId, dto, target, userId);
  }

  private async updateMatrixRow(
    issueId: number,
    dto: UpdateIssueDto,
    target: IssueMatrixTarget,
    userId?: number,
  ): Promise<IssueMatrixItem | EscalationOnlyResult> {
    if (
      dto.governmentAgencyIds === undefined &&
      dto.issueStatusId === undefined &&
      dto.escalation === undefined
    ) {
      throw new BadRequestException(
        'Provide governmentAgencyIds, issueStatusId or escalation.',
      );
    }

    if (
      dto.governmentAgencyIds !== undefined &&
      dto.governmentAgencyIds.length !== 1
    ) {
      throw new BadRequestException(
        'Exactly one primary government agency must be selected.',
      );
    }

    const items = await this.getMatrixItems(target);
    const item = items.find((candidate) => candidate.issueId === issueId);

    if (!item) {
      // The shared issue-matrix page lists more private-sector issues than this
      // matrix does. Those can still be flagged for the plenary, but nothing
      // else about them is editable from here.
      const escalation = dto.escalation;
      const isEscalationOnly =
        escalation !== undefined &&
        dto.governmentAgencyIds === undefined &&
        dto.issueStatusId === undefined;

      if (
        !isEscalationOnly ||
        !(await this.isSharedPrivateSectorIssue(issueId))
      ) {
        throw new NotFoundException(
          `Issue ${issueId} was not found in the issue matrix.`,
        );
      }

      await this.issueMatrixRepository.setIssueEscalation(issueId, escalation);

      return { issueId, escalation };
    }

    // CDC changes only the primary agency. Keep every additional agency and
    // its existing order. If an additional agency becomes primary, remove its
    // old position so the issue does not contain the same agency twice.
    const selectedPrimaryAgencyId = dto.governmentAgencyIds?.[0];
    const governmentAgencies =
      selectedPrimaryAgencyId === undefined
        ? undefined
        : [
            {
              stakeholderId: selectedPrimaryAgencyId,
              agencyOrder: 1,
            },
            ...item.governmentAgencies
              .filter(
                (agency) =>
                  agency.agencyOrder !== 1 &&
                  agency.id !== selectedPrimaryAgencyId,
              )
              .map((agency) => ({
                stakeholderId: agency.id,
                agencyOrder: agency.agencyOrder,
              })),
          ];

    if (dto.escalation !== undefined) {
      await this.issueMatrixRepository.setIssueEscalation(
        issueId,
        dto.escalation,
      );
    }

    // The working-group service owns issue writes: it validates the status and
    // the ministries, and swaps the agency rows inside one transaction.
    if (governmentAgencies !== undefined || dto.issueStatusId !== undefined) {
      const updated = await this.workingGroupIssuesService.update(
        issueId,
        {
          ...(governmentAgencies !== undefined && { governmentAgencies }),
          ...(dto.issueStatusId !== undefined && {
            issueStatusId: dto.issueStatusId,
          }),
        },
        undefined,
        userId,
      );

      if (governmentAgencies !== undefined) {
        await this.alertStakeholdersAboutReassignment(updated, item, userId);
      }
    }

    if (item.issueResolveId !== null) {
      await this.issueMatrixRepository.applyResolveOverrides(
        item.issueResolveId,
        {
          issueStatusId: dto.issueStatusId,
          clearAgencies: governmentAgencies !== undefined,
        },
      );
    }

    return this.findMatrixItem(issueId, target);
  }

  /** Read one row back out of the matrix, or fail if it is not part of it. */
  private async findMatrixItem(
    issueId: number,
    target: IssueMatrixTarget,
  ): Promise<IssueMatrixItem> {
    const items = await this.getMatrixItems(target);
    const item = items.find((candidate) => candidate.issueId === issueId);

    if (!item) {
      throw new NotFoundException(
        `Issue ${issueId} was not found in the CDC issue matrix.`,
      );
    }

    return item;
  }

  private async isSharedPrivateSectorIssue(issueId: number): Promise<boolean> {
    const findIssueDetail =
      this.workingGroupIssuesService.findOneForIssueMatrixDetail;

    if (typeof findIssueDetail !== 'function') {
      return false;
    }

    let issue: Awaited<
      ReturnType<WorkingGroupIssuesService['findOneForIssueMatrixDetail']>
    >;

    try {
      issue = await findIssueDetail.call(
        this.workingGroupIssuesService,
        issueId,
      );
    } catch (error) {
      if (error instanceof NotFoundException) {
        return false;
      }

      throw error;
    }

    if (!issue) {
      return false;
    }

    return (
      issue.stakeholder?.stakeholderType?.name?.trim().toLowerCase() ===
      'private sector'
    );
  }

  /** Notify the affected ministries and the PSWG that owns the issue. */
  private async alertStakeholdersAboutReassignment(
    updated: {
      issue?: { id: number; title?: string; governmentAgencies?: unknown };
    },
    previous: {
      primaryAgency?: { id: number; name: string } | null;
      workingGroup: { id: number; name: string };
    },
    userId?: number,
  ) {
    const issue = updated?.issue;
    const agencies = (issue?.governmentAgencies ?? []) as {
      agencyOrder: number;
      stakeholder: { id: number; name: string };
    }[];
    const primary = agencies.find((agency) => agency.agencyOrder === 1);

    if (!issue || !primary || !userId) return;

    try {
      await this.notifications.createForIssueAgencyReassigned({
        issueId: issue.id,
        issueTitle: issue.title ?? '',
        senderUserId: userId,
        newAgency: {
          id: primary.stakeholder.id,
          name: primary.stakeholder.name,
        },
        previousAgency: previous.primaryAgency
          ? {
              id: previous.primaryAgency.id,
              name: previous.primaryAgency.name,
            }
          : null,
        ownerWorkingGroup: previous.workingGroup,
      });
    } catch (error) {
      console.error('Failed to send reassignment notifications', error);
    }
  }

  private async findLatestResolutions(
    target: IssueMatrixTarget,
  ): Promise<SubmittedIssueResolve[]> {
    const resolutions =
      await this.issueMatrixRepository.findSubmittedIssueResolves(
        target.escalateName,
      );
    const newestFirst = [...resolutions].sort(
      (firstResolution, secondResolution) =>
        secondResolution.meetingSummary.updatedAt.getTime() -
          firstResolution.meetingSummary.updatedAt.getTime() ||
        secondResolution.id - firstResolution.id,
    );
    const latestByIssueId = new Map<number, SubmittedIssueResolve>();

    for (const resolution of newestFirst) {
      if (!latestByIssueId.has(resolution.issueId)) {
        latestByIssueId.set(resolution.issueId, resolution);
      }
    }

    return Array.from(latestByIssueId.values());
  }

  private async getMatrixItems(
    target: IssueMatrixTarget,
  ): Promise<IssueMatrixItem[]> {
    const [ministryResolutions, ownIssues] = await Promise.all([
      this.findLatestResolutions(target),
      this.workingGroupIssuesService.findIssuesCreatedByRole(target.roleName),
    ]);
    const itemsByIssueId = new Map<number, IssueMatrixItem>();

    for (const resolution of ministryResolutions) {
      itemsByIssueId.set(resolution.issueId, this.toMinistryItem(resolution));
    }

    for (const issue of ownIssues) {
      itemsByIssueId.set(issue.id, this.toOwnIssueItem(issue));
    }

    return Array.from(itemsByIssueId.values()).sort(
      (firstItem, secondItem) =>
        secondItem.submittedAt.getTime() - firstItem.submittedAt.getTime() ||
        secondItem.issueId - firstItem.issueId,
    );
  }

  private toMinistryItem(resolution: SubmittedIssueResolve): IssueMatrixItem {
    return {
      issueId: resolution.issueId,
      issueResolveId: resolution.id,
      meetingSummaryId: resolution.meetingSummaryId,
      source: 'MINISTRY',
      title: resolution.issue.title,
      description: resolution.issue.description,
      recommendation: resolution.issue.recommendation,
      category: resolution.issue.category,
      status: resolution.issue.issueStatus,
      workingGroup: resolution.issue.stakeholder,
      primaryAgency: this.getPrimaryAgency(resolution),
      governmentAgencies: this.toAgencies(resolution.issue.governmentAgencies),
      rgcDecision: resolution.rgcDecision,
      nextStep: resolution.nextStep,
      remark: resolution.remark,
      escalation: resolution.issue.escalation,
      submittedAt: resolution.meetingSummary.updatedAt,
    };
  }

  private toOwnIssueItem(
    issue: Awaited<
      ReturnType<WorkingGroupIssuesService['findIssuesCreatedByRole']>
    >[number],
  ): IssueMatrixItem {
    const primaryAgency = issue.governmentAgencies.find(
      (agency) => agency.agencyOrder === 1,
    );

    return {
      issueId: issue.id,
      issueResolveId: null,
      meetingSummaryId: null,
      source: 'OWN',
      title: issue.title,
      description: issue.description,
      recommendation: issue.recommendation,
      category: issue.category,
      status: issue.issueStatus,
      workingGroup: issue.stakeholder,
      primaryAgency: primaryAgency?.stakeholder ?? null,
      governmentAgencies: this.toAgencies(issue.governmentAgencies),
      rgcDecision: null,
      nextStep: null,
      remark: null,
      escalation: issue.escalation,
      submittedAt: issue.createdAt,
    };
  }

  /**
   * Flatten the agency rows into one ordered list. Fields are picked one by one
   * because the two sources select slightly different stakeholder columns.
   */
  private toAgencies(
    agencies: readonly IssueGovernmentAgency[],
  ): IssueMatrixAgency[] {
    return [...agencies]
      .sort(
        (firstAgency, secondAgency) =>
          firstAgency.agencyOrder - secondAgency.agencyOrder,
      )
      .map((agency) => ({
        id: agency.stakeholder.id,
        name: agency.stakeholder.name,
        logo: agency.stakeholder.logo,
        agencyOrder: agency.agencyOrder,
      }));
  }

  private getPrimaryAgency(resolution: SubmittedIssueResolve) {
    const primaryAgency = resolution.issue.governmentAgencies.find(
      (agency) => agency.agencyOrder === 1,
    );

    return primaryAgency?.stakeholder ?? null;
  }
}
