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

import {
  CdcInProgressStatus,
  ProgressReportDashboardStatus,
  ProgressReportDashboardType,
  RgcDecisionStatus,
} from '@/generated/prisma/client';

import {
  ProgressReportDashboardsRepository,
  type DashboardCountRow,
  type DashboardSnapshotRecord,
  type LiveDashboardSnapshotRecord,
} from './dashboards.repository';

// --- Response shapes -------------------------------------------------------
// This is the JSON both endpoints return. It mirrors the columns saved in
// progress_report_dashboards, so reading a saved dashboard needs no extra
// work, and the frontend gets raw counts and draws the bars itself.

export type DraftDashboardCards = {
  totalIssues: number;
  solved: number;
  inProgress: number;
  notAddressed: number;
  totalPrimaryAgencies: number;
  // Every active ministry, so the card can read "5 / 13".
  totalMinistries: number;
  // How the in-progress items split, judged by CDC-GPSF. Only the Plenary
  // dashboard has these; the PSWG one leaves them null.
  midProgress: number | null;
  earlyProgress: number | null;
};

export type DraftDashboardWorkingGroupRow = {
  workingGroupId: number;
  workingGroupName: string;
  total: number;
  solved: number;
  inProgress: number;
  notAddressed: number;
};

export type DraftDashboardAgencyRow = {
  agencyId: number;
  agencyName: string;
  total: number;
  solved: number;
  inProgress: number;
  notAddressed: number;
};

export type DraftDashboardCategoryRow = {
  categoryId: number;
  categoryName: string;
  total: number;
  solved: number;
  inProgress: number;
  notAddressed: number;
};

export type DraftDashboardPayload = {
  type: ProgressReportDashboardType;
  // DRAFT/FINAL means the numbers were saved; LIVE means they were calculated
  // for this request only and nothing was written.
  status: ProgressReportDashboardStatus;
  generatedAt: string | null;
  cards: DraftDashboardCards;
  byWorkingGroup: DraftDashboardWorkingGroupRow[];
  byPrimaryAgency: DraftDashboardAgencyRow[];
  byCategory: DraftDashboardCategoryRow[];
};

// What the role dashboards read: the published numbers plus the report they
// were published from, so a page can show which semester they belong to.
export type LiveDashboardPayload = DraftDashboardPayload & {
  report: {
    id: number;
    title: string;
    year: number;
    semester: string;
  };
};

// The counted numbers, before they are saved or turned into a payload.
type ComputedDashboard = {
  totalIssues: number;
  solved: number;
  inProgress: number;
  notAddressed: number;
  totalPrimaryAgencies: number;
  midProgress: number | null;
  earlyProgress: number | null;
  workingGroups: DashboardCountRow[];
  primaryAgencies: DashboardCountRow[];
  categories: DashboardCountRow[];
};

// The three buckets every chart is built from. They are matched against
// IssueStatuses.name without caring about upper/lower case. Any other status
// (Draft, Saved, New Submission) still counts towards the total issues, but
// has no bar of its own — the same rule the working group dashboard uses.
const STATUS_BUCKET_NAMES = {
  solved: 'solved',
  inProgress: 'in progress',
  notAddressed: 'not addressed',
};

type StatusBucket = 'solved' | 'inProgress' | 'notAddressed';

// A saved dashboard is either the draft or the final one. LIVE is only ever
// returned for numbers that were calculated without being saved.
export type SavedDashboardStatus =
  | typeof ProgressReportDashboardStatus.DRAFT
  | typeof ProgressReportDashboardStatus.FINAL;

// One row while it is still being counted. Same fields as the saved row.
type CountRow = DashboardCountRow;

// Busiest row first, then alphabetically — the same order the other
// dashboards use, so the charts read the same way everywhere.
function byTotalThenName<T extends { total: number; name: string }>(
  rows: T[],
): T[] {
  return [...rows].sort(
    (a, b) => b.total - a.total || a.name.localeCompare(b.name),
  );
}

@Injectable()
export class ProgressReportDashboardsService {
  constructor(
    private readonly dashboardsRepository: ProgressReportDashboardsRepository,
  ) {}

  // Calculates the current numbers and saves them, as the draft or as the
  // final. Generating the same one again replaces only its own numbers.
  async generate(
    progressReportId: number,
    type: ProgressReportDashboardType,
    userId: number,
    status: SavedDashboardStatus = ProgressReportDashboardStatus.DRAFT,
  ): Promise<DraftDashboardPayload> {
    const report = await this.ensureReportExists(progressReportId);

    const computed = await this.computeFor(type, report.year, progressReportId);
    const snapshot = await this.dashboardsRepository.saveSnapshot({
      progressReportId,
      type,
      status,
      userId,
      totalIssues: computed.totalIssues,
      solved: computed.solved,
      inProgress: computed.inProgress,
      notAddressed: computed.notAddressed,
      totalPrimaryAgencies: computed.totalPrimaryAgencies,
      midProgress: computed.midProgress,
      earlyProgress: computed.earlyProgress,
      workingGroups: computed.workingGroups,
      primaryAgencies: computed.primaryAgencies,
      categories: computed.categories,
    });

    const cap = await this.dashboardsRepository.countActiveMinistries();

    return this.toPayloadFromSnapshot(snapshot, cap);
  }

  // Reads one saved dashboard (the draft or the final). When that one has
  // never been generated, the current numbers are calculated on the fly and
  // returned as LIVE, so the page still shows the dashboard (nothing is
  // written until Generate is pressed).
  async findOne(
    progressReportId: number,
    type: ProgressReportDashboardType,
    status: SavedDashboardStatus = ProgressReportDashboardStatus.DRAFT,
  ): Promise<DraftDashboardPayload> {
    const report = await this.ensureReportExists(progressReportId);

    const snapshot = await this.dashboardsRepository.findSnapshot(
      progressReportId,
      type,
      status,
    );
    const cap = await this.dashboardsRepository.countActiveMinistries();

    if (snapshot) {
      return this.toPayloadFromSnapshot(snapshot, cap);
    }

    const computed = await this.computeFor(type, report.year, progressReportId);

    return this.toPayload(computed, {
      type,
      status: ProgressReportDashboardStatus.LIVE,
      generatedAt: null,
      totalMinistries: cap,
    });
  }

  // "Live": publishes the final dashboard so every role dashboard shows these
  // exact numbers. The numbers are copied, so issues changed afterwards never
  // move a published dashboard.
  async publishLive(
    progressReportId: number,
    type: ProgressReportDashboardType,
    userId: number,
  ): Promise<DraftDashboardPayload> {
    await this.ensureReportExists(progressReportId);

    const finalSnapshot = await this.dashboardsRepository.findSnapshot(
      progressReportId,
      type,
      ProgressReportDashboardStatus.FINAL,
    );

    if (!finalSnapshot) {
      throw new ConflictException(
        'Generate the final dashboard before making it live.',
      );
    }

    // Only one dashboard is live at a time, so the previous one goes first.
    await this.dashboardsRepository.deleteLiveSnapshots(type);

    const published = await this.dashboardsRepository.saveSnapshot({
      progressReportId,
      type,
      status: ProgressReportDashboardStatus.LIVE,
      userId,
      // Keep the date the final dashboard was generated, so the role
      // dashboards show the same "last update" as the final page.
      generatedAt: finalSnapshot.generatedAt,
      totalIssues: finalSnapshot.totalIssues,
      solved: finalSnapshot.solved,
      inProgress: finalSnapshot.inProgress,
      notAddressed: finalSnapshot.notAddressed,
      totalPrimaryAgencies: finalSnapshot.totalPrimaryAgencies,
      midProgress: finalSnapshot.midProgress,
      earlyProgress: finalSnapshot.earlyProgress,
      workingGroups: finalSnapshot.workingGroups.map((row) => ({
        id: row.stakeholder.id,
        name: row.stakeholder.name,
        total: row.total,
        solved: row.solved,
        inProgress: row.inProgress,
        notAddressed: row.notAddressed,
      })),
      primaryAgencies: finalSnapshot.primaryAgencies.map((row) => ({
        id: row.stakeholder.id,
        name: row.stakeholder.name,
        total: row.total,
        solved: row.solved,
        inProgress: row.inProgress,
        notAddressed: row.notAddressed,
      })),
      categories: finalSnapshot.categories.map((row) => ({
        id: row.category.id,
        name: row.category.name,
        total: row.total,
        solved: row.solved,
        inProgress: row.inProgress,
        notAddressed: row.notAddressed,
      })),
    });

    const totalMinistries =
      await this.dashboardsRepository.countActiveMinistries();

    return this.toPayloadFromSnapshot(published, totalMinistries);
  }

  // The dashboard everybody sees. Null while CDC-GPSF has published nothing.
  async findLive(
    type: ProgressReportDashboardType,
  ): Promise<LiveDashboardPayload | null> {
    const snapshot =
      await this.dashboardsRepository.findLatestLiveSnapshot(type);

    if (!snapshot) {
      return null;
    }

    const totalMinistries =
      await this.dashboardsRepository.countActiveMinistries();

    return {
      ...this.toPayloadFromSnapshot(snapshot, totalMinistries),
      report: this.toReportInfo(snapshot),
    };
  }

  // The report a published dashboard was generated from.
  private toReportInfo(snapshot: LiveDashboardSnapshotRecord) {
    return {
      id: snapshot.progressReport.id,
      title: snapshot.progressReport.title,
      year: snapshot.progressReport.year,
      semester: snapshot.progressReport.semester,
    };
  }

  // Turns freshly counted numbers into the JSON the endpoints return.
  private toPayload(
    computed: ComputedDashboard,
    meta: {
      type: ProgressReportDashboardType;
      status: ProgressReportDashboardStatus;
      generatedAt: Date | null;
      totalMinistries: number;
    },
  ): DraftDashboardPayload {
    return {
      type: meta.type,
      status: meta.status,
      generatedAt: meta.generatedAt ? meta.generatedAt.toISOString() : null,
      cards: {
        totalIssues: computed.totalIssues,
        solved: computed.solved,
        inProgress: computed.inProgress,
        notAddressed: computed.notAddressed,
        totalPrimaryAgencies: computed.totalPrimaryAgencies,
        totalMinistries: meta.totalMinistries,
        midProgress: computed.midProgress,
        earlyProgress: computed.earlyProgress,
      },
      byWorkingGroup: byTotalThenName(computed.workingGroups).map((row) => ({
        workingGroupId: row.id,
        workingGroupName: row.name,
        total: row.total,
        solved: row.solved,
        inProgress: row.inProgress,
        notAddressed: row.notAddressed,
      })),
      byPrimaryAgency: byTotalThenName(computed.primaryAgencies).map((row) => ({
        agencyId: row.id,
        agencyName: row.name,
        total: row.total,
        solved: row.solved,
        inProgress: row.inProgress,
        notAddressed: row.notAddressed,
      })),
      byCategory: byTotalThenName(computed.categories).map((row) => ({
        categoryId: row.id,
        categoryName: row.name,
        total: row.total,
        solved: row.solved,
        inProgress: row.inProgress,
        notAddressed: row.notAddressed,
      })),
    };
  }

  // Turns a saved dashboard into the same JSON, so a generated dashboard and
  // a live one can never come out looking different.
  private toPayloadFromSnapshot(
    snapshot: DashboardSnapshotRecord,
    totalMinistries: number,
  ): DraftDashboardPayload {
    return this.toPayload(
      {
        totalIssues: snapshot.totalIssues,
        solved: snapshot.solved,
        inProgress: snapshot.inProgress,
        notAddressed: snapshot.notAddressed,
        totalPrimaryAgencies: snapshot.totalPrimaryAgencies,
        midProgress: snapshot.midProgress,
        earlyProgress: snapshot.earlyProgress,
        workingGroups: snapshot.workingGroups.map((row) => ({
          id: row.stakeholder.id,
          name: row.stakeholder.name,
          total: row.total,
          solved: row.solved,
          inProgress: row.inProgress,
          notAddressed: row.notAddressed,
        })),
        primaryAgencies: snapshot.primaryAgencies.map((row) => ({
          id: row.stakeholder.id,
          name: row.stakeholder.name,
          total: row.total,
          solved: row.solved,
          inProgress: row.inProgress,
          notAddressed: row.notAddressed,
        })),
        categories: snapshot.categories.map((row) => ({
          id: row.category.id,
          name: row.category.name,
          total: row.total,
          solved: row.solved,
          inProgress: row.inProgress,
          notAddressed: row.notAddressed,
        })),
      },
      {
        type: snapshot.type,
        status: snapshot.status,
        generatedAt: snapshot.generatedAt,
        totalMinistries,
      },
    );
  }

  private async ensureReportExists(progressReportId: number) {
    const report =
      await this.dashboardsRepository.findActiveReport(progressReportId);

    if (!report) {
      throw new NotFoundException('Progress report not found.');
    }

    return report;
  }

  // PSWG counts the ministries' issues; Plenary counts the RGC decisions.
  private computeFor(
    type: ProgressReportDashboardType,
    year: number,
    progressReportId: number,
  ): Promise<ComputedDashboard> {
    return type === ProgressReportDashboardType.PLENARY
      ? this.computePlenary(year, progressReportId)
      : this.compute(year);
  }

  // Counts the RGC decisions of the report's year and splits those counts by
  // status, ministry, measure category and working group. The in-progress ones
  // are split again into Mid and Early progress, from what CDC-GPSF judged in
  // this report; decisions it has not judged yet are in neither half.
  private async computePlenary(
    year: number,
    progressReportId: number,
  ): Promise<ComputedDashboard> {
    const raw = await this.dashboardsRepository.getCountedRgcDecisions(
      year,
      progressReportId,
    );

    const ministryRows = this.buildEmptyRows(raw.ministries);
    const workingGroupRows = this.buildEmptyRows(raw.workingGroups);
    const categoryRows = this.buildEmptyRows(raw.categories);

    // decision id → Mid or Early progress, as judged in this report.
    const judgementByDecisionId = new Map(
      raw.cdcJudgements
        .filter((judgement) => judgement.cdcInProgressStatus !== null)
        .map((judgement) => [
          judgement.plenaryDecisionId,
          judgement.cdcInProgressStatus,
        ]),
    );

    const cards = { totalIssues: 0, solved: 0, inProgress: 0, notAddressed: 0 };
    const ministriesWithDecisions = new Set<number>();
    let midProgress = 0;
    let earlyProgress = 0;

    for (const decision of raw.decisions) {
      const bucket = this.toDecisionBucket(decision.status);

      cards.totalIssues += 1;
      cards[bucket] += 1;

      if (bucket === 'inProgress') {
        const judgement = judgementByDecisionId.get(decision.id);
        if (judgement === CdcInProgressStatus.MID_PROGRESS) midProgress += 1;
        if (judgement === CdcInProgressStatus.EARLY_PROGRESS)
          earlyProgress += 1;
      }

      ministriesWithDecisions.add(decision.stakeholderId);
      this.addToRow(ministryRows.get(decision.stakeholderId), bucket);

      // A decision counts once for each working group it came from, so a
      // decision without a linked issue has no working group bar.
      const decisionWorkingGroupIds = new Set(
        decision.decisionIssues.map((link) => link.issue.stakeholderId),
      );
      for (const workingGroupId of decisionWorkingGroupIds) {
        this.addToRow(workingGroupRows.get(workingGroupId), bucket);
      }

      this.addToRow(categoryRows.get(decision.categoryId), bucket);
    }

    return {
      totalIssues: cards.totalIssues,
      solved: cards.solved,
      inProgress: cards.inProgress,
      notAddressed: cards.notAddressed,
      totalPrimaryAgencies: ministriesWithDecisions.size,
      midProgress,
      earlyProgress,
      workingGroups: [...workingGroupRows.values()],
      primaryAgencies: [...ministryRows.values()],
      categories: [...categoryRows.values()],
    };
  }

  // The RGC decision status is a real enum, so it maps straight to a bucket.
  private toDecisionBucket(status: RgcDecisionStatus): StatusBucket {
    if (status === RgcDecisionStatus.SOLVED) return 'solved';
    if (status === RgcDecisionStatus.IN_PROGRESS) return 'inProgress';

    return 'notAddressed';
  }

  // Counts every issue that is in a ministry's hands for the report's year,
  // and splits those counts by status, working group, primary agency, and
  // category.
  private async compute(year: number): Promise<ComputedDashboard> {
    const raw = await this.dashboardsRepository.getCountedIssues(year);

    // status id → which of the three buckets it belongs to (or undefined).
    const bucketByStatusId = new Map<number, StatusBucket>();
    for (const status of raw.statuses) {
      const bucket = this.toStatusBucket(status.name);
      if (bucket) bucketByStatusId.set(status.id, bucket);
    }

    // Start every chart with all of its rows at zero, so a working group,
    // ministry, or category without issues still shows an empty bar.
    const workingGroupRows = this.buildEmptyRows(raw.workingGroups);
    const agencyRows = this.buildEmptyRows(raw.ministries);
    const categoryRows = this.buildEmptyRows(raw.categories);

    const cards = { totalIssues: 0, solved: 0, inProgress: 0, notAddressed: 0 };
    const ministriesWithIssues = new Set<number>();

    for (const issue of raw.issues) {
      const bucket = bucketByStatusId.get(issue.issueStatusId);

      cards.totalIssues += 1;
      if (bucket) cards[bucket] += 1;

      // The query only returns issues that have a primary agency, so there is
      // always one row here: the ministry responsible for the issue.
      const primaryAgencyId = issue.governmentAgencies[0]?.stakeholderId;
      if (primaryAgencyId !== undefined) {
        ministriesWithIssues.add(primaryAgencyId);
        this.addToRow(agencyRows.get(primaryAgencyId), bucket);
      }

      // The issue carries its owning working group and its category.
      this.addToRow(workingGroupRows.get(issue.stakeholderId), bucket);
      this.addToRow(categoryRows.get(issue.categoryId), bucket);
    }

    return {
      totalIssues: cards.totalIssues,
      solved: cards.solved,
      inProgress: cards.inProgress,
      notAddressed: cards.notAddressed,
      totalPrimaryAgencies: ministriesWithIssues.size,
      // Only the Plenary dashboard splits its in-progress items.
      midProgress: null,
      earlyProgress: null,
      workingGroups: [...workingGroupRows.values()],
      primaryAgencies: [...agencyRows.values()],
      categories: [...categoryRows.values()],
    };
  }

  // "Solved" → solved, "In Progress" → inProgress, "Not Addressed" →
  // notAddressed. Every other status name returns null.
  private toStatusBucket(statusName: string): StatusBucket | null {
    const name = statusName.trim().toLowerCase();

    if (name === STATUS_BUCKET_NAMES.solved) return 'solved';
    if (name === STATUS_BUCKET_NAMES.inProgress) return 'inProgress';
    if (name === STATUS_BUCKET_NAMES.notAddressed) return 'notAddressed';

    return null;
  }

  // id → an all-zero row, keyed so counting is a simple lookup.
  private buildEmptyRows(items: { id: number; name: string }[]) {
    return new Map<number, CountRow>(
      items.map((item) => [
        item.id,
        {
          id: item.id,
          name: item.name,
          total: 0,
          solved: 0,
          inProgress: 0,
          notAddressed: 0,
        },
      ]),
    );
  }

  // Adds one issue to a chart row. The row is missing when the stakeholder is
  // inactive or deleted; such issues only count towards the total.
  private addToRow(row: CountRow | undefined, bucket?: StatusBucket) {
    if (!row) return;

    row.total += 1;
    if (bucket) row[bucket] += 1;
  }
}
