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

import { CaslAbilityFactory } from '@/casl/casl-ability.factory';
import { IssuesService } from '@/modules/issues/issues.service';
import { MeetingRequestsService } from '@/modules/meeting-requests/meeting-requests.service';
import { MeetingSummaryService } from '@/modules/meeting-summary/meeting-summary.service';
import { PlenaryService } from '@/modules/plenary/plenary.service';
import { ProgressReportsService } from '@/modules/progress-reports/progress-reports.service';
import { RgcDecisionService } from '@/modules/rgc-decision/rgc-decision.service';
import { WorkingGroupIssuesService } from '@/modules/working-group-issues/working-group-issues.service';

import type { SearchResponse, SearchResult } from './search.types';

// Nothing is searched until the reader has typed at least this much.
const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT = 5;

// Rows come back from each module in its own shape, so they are read
// defensively before being turned into a hit.
type UnknownRecord = Record<string, unknown>;

function readString(row: UnknownRecord, ...keys: string[]): string {
  for (const key of keys) {
    const value = row[key];

    if (typeof value === 'string' && value.trim()) return value.trim();
  }

  return '';
}

function readNumber(row: UnknownRecord, ...keys: string[]): number | undefined {
  for (const key of keys) {
    const value = row[key];

    if (typeof value === 'number') return value;
  }

  return undefined;
}

function toRows(result: unknown): UnknownRecord[] {
  if (Array.isArray(result)) return result as UnknownRecord[];

  const data = (result as { data?: unknown; items?: unknown } | null)?.data;
  if (Array.isArray(data)) return data as UnknownRecord[];

  const items = (result as { items?: unknown } | null)?.items;
  if (Array.isArray(items)) return items as UnknownRecord[];

  return [];
}

@Injectable()
export class SearchService {
  private readonly logger = new Logger(SearchService.name);

  constructor(
    private readonly casl: CaslAbilityFactory,
    private readonly workingGroupIssues: WorkingGroupIssuesService,
    private readonly issues: IssuesService,
    private readonly progressReports: ProgressReportsService,
    private readonly meetingRequests: MeetingRequestsService,
    private readonly meetingSummaries: MeetingSummaryService,
    private readonly plenary: PlenaryService,
    private readonly rgcDecisions: RgcDecisionService,
  ) {}

  // Looks through every group the caller is allowed to read. Each group calls
  // the module's own list service, so its permission rules and per-role
  // scoping are exactly the ones that screen already uses.
  async search(
    userId: number,
    query: string,
    limit = DEFAULT_LIMIT,
  ): Promise<SearchResponse> {
    const search = query.trim();

    if (search.length < MIN_QUERY_LENGTH) {
      return { query: search, results: [] };
    }

    const { permissions } = await this.casl.getGrantsForUser(userId);
    const can = (permission: string) => permissions.includes(permission);

    const groups = await Promise.allSettled([
      this.findIssues(userId, search, limit, can),
      this.findProgressReports(userId, search, limit, can),
      this.findMeetings(userId, search, limit, can),
      this.findPlenary(userId, search, limit),
    ]);

    const results = groups.flatMap((group) => {
      if (group.status === 'fulfilled') return group.value;

      // One group failing (no stakeholder, no permission) must not take the
      // whole search down.
      this.logger.debug(`Search group skipped: ${String(group.reason)}`);
      return [];
    });

    return { query: search, results };
  }

  // CDC and CEFP search their issue matrix; everyone else searches the working
  // group issues they own.
  private async findIssues(
    userId: number,
    search: string,
    limit: number,
    can: (permission: string) => boolean,
  ): Promise<SearchResult[]> {
    if (can('read CdcIssueMatrix') || can('read CefpIssueMatrix')) {
      const target = await this.issues.resolveTarget(userId);
      const page = await this.issues.findAll({ page: 1, limit: 50 }, target);
      const rows = toRows(page).filter((row) =>
        readString(row, 'title').toLowerCase().includes(search.toLowerCase()),
      );

      return rows.slice(0, limit).map((row) => this.toIssueResult(row));
    }

    if (!can('read WorkingGroupIssue')) return [];

    const page = await this.workingGroupIssues.findMyIssues(userId, {
      page: 1,
      limit,
      search,
    });

    return toRows(page).map((row) => this.toIssueResult(row));
  }

  private toIssueResult(row: UnknownRecord): SearchResult {
    const issueId = readNumber(row, 'issueId', 'id') ?? 0;
    const category = row.category as UnknownRecord | undefined;

    return {
      group: 'issues',
      id: issueId,
      title: readString(row, 'title') || 'Issue',
      subtitle: category ? readString(category, 'name') : undefined,
      meta: { issueId },
    };
  }

  // The progress report list already narrows itself to a ministry's own
  // reports when the caller belongs to one.
  private async findProgressReports(
    userId: number,
    search: string,
    limit: number,
    can: (permission: string) => boolean,
  ): Promise<SearchResult[]> {
    if (!can('read ProgressReport') && !can('read ProgressReportMinistry')) {
      return [];
    }

    const page = await this.progressReports.findAll(
      { page: 1, limit, search },
      userId,
    );

    return toRows(page).map((row) => {
      const id = readNumber(row, 'id') ?? 0;
      const year = readNumber(row, 'year');
      const semester = readString(row, 'semester');

      return {
        group: 'progressReports' as const,
        id,
        title: readString(row, 'title') || 'Progress report',
        subtitle: [semester, year].filter(Boolean).join(' '),
        meta: { progressReportId: id },
      };
    });
  }

  // Meeting requests and meeting summaries share one group.
  private async findMeetings(
    userId: number,
    search: string,
    limit: number,
    can: (permission: string) => boolean,
  ): Promise<SearchResult[]> {
    const results: SearchResult[] = [];

    if (can('read MeetingRequest')) {
      const page = await this.meetingRequests.findAll(
        { page: 1, limit, search },
        userId,
      );

      results.push(
        ...toRows(page).map((row) => {
          const id = readNumber(row, 'id') ?? 0;

          return {
            group: 'meetings' as const,
            id,
            title: readString(row, 'title') || 'Meeting request',
            subtitle: readString(row, 'status'),
            meta: { meetingRequestId: id },
          };
        }),
      );
    }

    if (can('read MeetingSummary')) {
      const page = await this.meetingSummaries.findAll(
        { page: 1, limit, search },
        userId,
      );

      results.push(
        ...toRows(page).map((row) => {
          const id = readNumber(row, 'id') ?? 0;

          return {
            group: 'meetings' as const,
            id,
            title: readString(row, 'title') || 'Meeting summary',
            subtitle: readString(row, 'status'),
            meta: { meetingSummaryId: id },
          };
        }),
      );
    }

    return results.slice(0, limit);
  }

  // Plenaries and their RGC decisions. Both list services scope by the
  // caller's ministry on their own, so no extra filtering is needed here.
  private async findPlenary(
    userId: number,
    search: string,
    limit: number,
  ): Promise<SearchResult[]> {
    const [plenaries, decisions] = await Promise.allSettled([
      this.plenary.findAll({ page: 1, limit, search }, userId),
      this.rgcDecisions.findAll({ page: 1, limit, search }, userId),
    ]);

    const results: SearchResult[] = [];

    if (plenaries.status === 'fulfilled') {
      results.push(
        ...toRows(plenaries.value).map((row) => {
          const id = readNumber(row, 'id') ?? 0;

          return {
            group: 'plenary' as const,
            id,
            title: readString(row, 'name', 'title') || 'Plenary',
            subtitle: readString(row, 'status'),
            meta: { plenaryId: id },
          };
        }),
      );
    }

    if (decisions.status === 'fulfilled') {
      results.push(
        ...toRows(decisions.value).map((row) => {
          const id = readNumber(row, 'id') ?? 0;

          return {
            group: 'plenary' as const,
            id,
            title: readString(row, 'decision', 'title') || 'RGC decision',
            subtitle: readString(row, 'focalPerson'),
            meta: { rgcDecisionId: id },
          };
        }),
      );
    }

    return results.slice(0, limit);
  }
}
