import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';

import { AuditReport, ReportStatus, StageType } from '../entities/audit-report.entity';
import { AuditReportClause } from '../entities/audit-report-clause.entity';
import { AuditReportImage } from '../entities/audit-report-image.entity';
import { parseRich, RichBlock, stripRich } from './rich-text.util';
import { AuditReportSection } from '../entities/audit-report-section.entity';
import { AuditReportAttendance } from '../entities/audit-report-attendance.entity';
import { ClauseLibrary } from '../entities/clause-library.entity';
import { ReportTemplate } from '../entities/report-template.entity';

import { CreateReportDto } from '../dto/create-report.dto';
import { UpdateHeaderDto } from '../dto/update-header.dto';
import { UpsertClauseDto, BulkClausesDto } from '../dto/upsert-clause.dto';
import { AttendeeDto } from '../dto/section-and-attendance.dto';
import { RaiseNcDto } from '../dto/workflow.dto';

import {
  CHANGE_DETAIL_QUESTIONS,
  CONCLUSION_OPTIONS,
  CONFIDENTIALITY,
  DECLARATIONS,
  DISCLAIMER,
  LIBRARY_SCOPE,
  MARK_USE_OPTIONS,
  REPORT_NO_PREFIX,
  SECTION_DEFS,
  STAGE1_RECOMMENDATION_OPTIONS,
  STAGE_LABEL,
  sectionAppliesTo,
} from '../report.constants';
import { defaultSectionBody, labelsFor, profileFor, standardsOf } from '../report.standards';

import { PreviousNcService } from '../../previous-nc/services/previous-nc.service';

/** Header fields the auditor may override. Anything else sent in `fields` is rejected. */
export const EDITABLE_FIELDS = [
  'name', 'address', 'site_address', 'employees', 'emails', 'contact_person', 'telephone', 'email',
  'multisite', 'scope', 'cert_no', 'ea_code', 'accreditation',
  'lead_auditor', 'auditors', 'technical_expert', 'man_days', 'audit_date',
] as const;

const EDITABLE_STATUSES: ReportStatus[] = ['draft', 'returned'];

// ───────────────────────── small helpers ─────────────────────────
const s = (v: any): string => (v === null || v === undefined ? '' : String(v).trim());
const pick = (o: any, ...keys: string[]): string => {
  for (const k of keys) {
    const v = s(o?.[k]);
    if (v) return v;
  }
  return '';
};
const parseIds = (v: any): number[] => {
  if (v === null || v === undefined || v === '') return [];
  let x = v;
  if (typeof x === 'string') {
    try { x = JSON.parse(x); } catch { x = x.split(',').map((t: string) => t.trim()); }
  }
  if (!Array.isArray(x)) x = [x];
  return x.map((n: any) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0);
};
export const toDMY = (v: any): string => {
  if (!v) return '';
  const d = v instanceof Date ? v : new Date(String(v).length === 10 ? `${v}T00:00:00` : v);
  if (isNaN(d.getTime())) return s(v);
  const p = (n: number) => String(n).padStart(2, '0');
  return `${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()}`;
};
const paragraphs = (t: string | null | undefined): string[] =>
  s(t).split(/\r?\n/).map((x) => x.trim()).filter(Boolean);

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

  constructor(
    @InjectRepository(AuditReport, 'scheme_dbs') private readonly reports: Repository<AuditReport>,
    @InjectRepository(AuditReportClause, 'scheme_dbs') private readonly clauses: Repository<AuditReportClause>,
    @InjectRepository(AuditReportSection, 'scheme_dbs') private readonly sections: Repository<AuditReportSection>,
    @InjectRepository(AuditReportImage, 'scheme_dbs') private readonly images: Repository<AuditReportImage>,
    @InjectRepository(AuditReportAttendance, 'scheme_dbs') private readonly attendance: Repository<AuditReportAttendance>,
    @InjectRepository(ClauseLibrary, 'scheme_dbs') private readonly library: Repository<ClauseLibrary>,
    @InjectRepository(ReportTemplate, 'scheme_dbs') private readonly templates: Repository<ReportTemplate>,
    @InjectDataSource('scheme_dbs') private readonly db: DataSource,
    // @InjectDataSource('scheme_db') private readonly ncDb: DataSource,

    private readonly nc: PreviousNcService,
  ) { }

  // ═════════════════════════════ ACCESS ═════════════════════════════

  private readonly SUPER_ADMIN_IDS = (process.env.AUDIT_REPORT_SUPER_ADMIN_IDS || '1,8,29')
    .split(',').map((x) => Number(x.trim())).filter(Boolean);
  private readonly norm = (x: string) => String(x || '').toLowerCase().replace(/[\s_-]+/g, '');

  /** Roles of a user, normalised ("Super-admin" -> "superadmin"), plus their scheme. */
  private async userScope(userId: number) {
    const [u] = await this.db.query(`SELECT id, primary_scheme FROM users WHERE id = ? LIMIT 1`, [userId]);
    const rows = await this.db.query(
      `SELECT r.name FROM user_roles ur INNER JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?`,
      [userId],
    );
    const roles = (rows as any[]).map((r) => this.norm(r.name));
    return { scheme: (u?.primary_scheme as string | null) ?? null, roles };
  }

  /**
   * Same rule as the QAQC report approvals module: super-admin ids and admin / superadmin roles see every scheme;
   * the `scheme` role (or the permission action `report-approve`) works only on audits of the user's own scheme (QRS / TQS).
   * Without a companyId it answers "may this user approve reports at all?".
   */
  async canApprove(userId: number, companyId?: number): Promise<boolean> {
    if (!userId) return false;
    try {
      if (this.SUPER_ADMIN_IDS.includes(Number(userId))) return true;
      const { scheme, roles } = await this.userScope(userId);
      if (roles.some((r) => ['superadmin', 'admin'].includes(r))) return true;
      // 2026-11-10: coordinators approve from the My Audits "Approve" column (owner's request), any scheme.
      if (roles.includes('coordinator')) return true;

      const extra = (process.env.AUDIT_REPORT_APPROVER_ROLES || 'scheme').split(',').map(this.norm).filter(Boolean);
      let allowed = roles.some((r) => extra.includes(r));
      if (!allowed) {
        const perm = await this.db.query(
          `SELECT 1 FROM user_permissions up INNER JOIN permissions p ON p.id = up.permission_id
            WHERE up.user_id = ? AND p.action = 'report-approve' LIMIT 1`,
          [userId],
        );
        allowed = (perm as any[]).length > 0;
      }
      if (!allowed) return false;
      if (!companyId) return true;
      if (!scheme) return false;
      const [c] = await this.db.query(`SELECT scheme FROM companies WHERE id = ? LIMIT 1`, [companyId]);
      return !!c && c.scheme === scheme;
    } catch (e: any) {
      this.logger.error(`[REPORT-PERM] approve lookup failed: ${e.message}`);
      return false;
    }
  }

  /** Staff who can see every report: NC view-all roles (super-admin, coordinator, marketing) or any approver role. */
  async canViewAll(userId: number): Promise<boolean> {
    if (await this.nc.userCanViewAll(userId)) return true;
    return this.canApprove(userId);
  }

  /** Creator, the lead / co auditor of the audit, a scheme approver of that company, or a view-all user. */
  async assertAccess(report: AuditReport, userId: number): Promise<void> {
    if (report.created_by === userId) return;
    if (await this.canViewAll(userId)) return;
    if (await this.canApprove(userId, report.company_id)) return;
    const ids = await this.rowAuditorIds(report.audit_schedule_row_id);
    if (ids.has(userId)) return;
    throw new ForbiddenException('You do not have access to this audit report.');
  }

  private async rowAuditorIds(rowId: number): Promise<Set<number>> {
    const [row] = await this.db.query(`SELECT lead_auditor_id FROM audit_schedule_rows WHERE id = ? LIMIT 1`, [rowId]);
    const co = await this.db.query(`SELECT auditor_id FROM audit_row_co_auditors WHERE row_id = ?`, [rowId]);
    return new Set<number>([Number(row?.lead_auditor_id) || 0, ...(co as any[]).map((c) => Number(c.auditor_id))].filter(Boolean));
  }

  private assertEditable(report: AuditReport) {
    if (!EDITABLE_STATUSES.includes(report.status)) {
      throw new ConflictException(`Report is ${report.status} and can no longer be edited.`);
    }
  }

  async getReportOrFail(id: number): Promise<AuditReport> {
    const r = await this.reports.findOne({ where: { id } });
    if (!r) throw new NotFoundException(`Audit report ${id} not found`);
    return r;
  }

  /** Load + access check + (optional) editable check. Used by every write endpoint. */
  async loadForWrite(id: number, userId: number): Promise<AuditReport> {
    const r = await this.getReportOrFail(id);
    await this.assertAccess(r, userId);
    this.assertEditable(r);
    return r;
  }

  // ═════════════════════════════ CRM PRE-FILL ═════════════════════════════

  private async loadUserNames(ids: number[]): Promise<Map<number, string>> {
    const out = new Map<number, string>();
    if (!ids.length) return out;
    const rows = await this.db.query(
      `SELECT id, firstName, lastName, email FROM users WHERE id IN (${ids.map(() => '?').join(',')})`,
      ids,
    );
    for (const u of rows as any[]) {
      const n = `${s(u.firstName)} ${s(u.lastName)}`.trim() || s(u.email);
      out.set(Number(u.id), n);
    }
    return out;
  }

  /** extras.hidden_standards / dto.exclude_standard_ids -> clean list of standards.id values that exist in `among`. */
  private sanitizeHidden(raw: unknown, among: { id: number }[]): number[] {
    if (!Array.isArray(raw)) return [];
    const ids = among.map((a) => Number(a.id));
    return Array.from(new Set(raw.map((v) => Number(v)).filter((n) => Number.isInteger(n) && n > 0 && (!ids.length || ids.includes(n)))));
  }

  /**
   * Combined reports created before 2026-11-04 stored all_standards without ids. Add the ids (matched by name against
   * the booked standards) and booked_standards once, so the "standards shown on the report" dropdown works for them too.
   */
  private async ensureStandardIds(r: AuditReport): Promise<void> {
    if (r.standard_id !== 0) return;
    const all: any[] = Array.isArray(r.crm_json?.all_standards) ? r.crm_json.all_standards : [];
    const hasIds = all.length > 0 && all.every((z) => z && z.id != null);
    if (hasIds && Array.isArray(r.crm_json?.booked_standards)) return;
    const booked = await this.rowStandards(r.audit_schedule_row_id);
    if (!booked.length) return;
    const withIds = all.map((z) => {
      if (z?.id != null) return z;
      const hit = booked.find((b) => b.name === s(z?.name));
      return hit ? { ...z, id: hit.id } : z;
    });
    r.crm_json = {
      ...(r.crm_json ?? {}),
      all_standards: withIds,
      booked_standards: booked.map((x) => ({ id: x.id, name: x.name, title: x.title })),
    };
    await this.reports.save(r);
  }

  /** Combined report: booked standards for the editor's "standards shown on the report" dropdown (ids only exist on reports created after 2026-11-04). */
  private standardsForEditor(r: AuditReport): { id: number; name: string; title: string }[] {
    if (r.standard_id !== 0) return [];
    const list: any[] = Array.isArray(r.crm_json?.booked_standards) ? r.crm_json.booked_standards
      : Array.isArray(r.crm_json?.all_standards) ? r.crm_json.all_standards : [];
    return list.filter((z) => z && z.id != null).map((z) => ({ id: Number(z.id), name: s(z.name), title: s(z.title) }));
  }

  /** Standards this audit row is booked for (row table first, then the request's standard_ids). */
  async rowStandards(rowId: number): Promise<{ id: number; name: string; title: string }[]> {
    const ids = new Set<number>();
    (await this.db.query(`SELECT standard_id FROM audit_schedule_row_standards WHERE row_id = ?`, [rowId]))
      .forEach((r: any) => ids.add(Number(r.standard_id)));
    const [req] = await this.db.query(
      `SELECT standard_ids FROM audit_requests
        WHERE audit_schedule_row_id = ? OR id = (SELECT audit_request_id FROM audit_schedule_rows WHERE id = ?)
        ORDER BY id DESC LIMIT 1`,
      [rowId, rowId],
    );
    if (req) parseIds(req.standard_ids).forEach((i) => ids.add(i));
    if (!ids.size) return [];
    const list = Array.from(ids);
    const rows = await this.db.query(
      `SELECT id, name, title FROM standards WHERE id IN (${list.map(() => '?').join(',')}) ORDER BY id`,
      list,
    );
    return (rows as any[]).map((r) => ({ id: Number(r.id), name: s(r.name), title: s(r.title) }));
  }

  /** Overview of one audit row for the "Create report" screen / Postman: stage guess, standards, existing reports. */
  async context(rowId: number, userId: number) {
    const [row] = await this.db.query(
      `SELECT r.id, r.audit_code, r.audit_type, r.audit_stage, r.status, r.company_id, c.name AS company_name, c.scheme,
              s.schedule_date
         FROM audit_schedule_rows r
         LEFT JOIN companies c ON c.id = r.company_id
         LEFT JOIN audit_schedules s ON s.id = r.schedule_id
        WHERE r.id = ? LIMIT 1`,
      [rowId],
    );
    if (!row) throw new NotFoundException(`Audit ${rowId} not found`);
    return {
      audit: row,
      suggested_stage_type: this.inferStage(row.audit_type, row.audit_stage),
      standards: await this.rowStandards(rowId),
      can_combine: (await this.rowStandards(rowId)).length > 1,
      reports: await this.byAudit(rowId, userId),
    };
  }

  /**
   * Everything the report can take from the CRM. Pure read, nothing is written.
   * Tables: audit_schedule_rows (+ audit_schedules for the date, audit_row_co_auditors for the team),
   * companies (+ countries), audit_requests, new_certificate, standards, users.
   */
  async loadCrm(rowId: number, standardId: number) {
    const [row] = await this.db.query(
      `SELECT r.*, s.schedule_date FROM audit_schedule_rows r
         LEFT JOIN audit_schedules s ON s.id = r.schedule_id WHERE r.id = ? LIMIT 1`,
      [rowId],
    );
    if (!row) throw new NotFoundException(`Audit ${rowId} not found`);

    const [company] = await this.db.query(
      `SELECT c.*, k.name AS country_name FROM companies c LEFT JOIN countries k ON k.id = c.country_id WHERE c.id = ? LIMIT 1`,
      [row.company_id],
    );
    if (!company) throw new BadRequestException('The audit has no company linked to it.');

    const [std] = await this.db.query(`SELECT id, name, title FROM standards WHERE id = ? LIMIT 1`, [standardId]);
    if (!std) throw new NotFoundException(`Standard ${standardId} not found`);

    const [req] = await this.db.query(
      `SELECT * FROM audit_requests WHERE audit_schedule_row_id = ? OR id = ? ORDER BY (audit_schedule_row_id = ?) DESC, id DESC LIMIT 1`,
      [rowId, row.audit_request_id ?? 0, rowId],
    );

    const [cert] = await this.db.query(
      `SELECT * FROM new_certificate WHERE company_id = ? AND standard_id = ?
        ORDER BY (status = 'active') DESC, id DESC LIMIT 1`,
      [row.company_id, standardId],
    );

    // Team
    const leadId = Number(row.lead_auditor_id) || 0;
    const coIds = ((await this.db.query(`SELECT auditor_id FROM audit_row_co_auditors WHERE row_id = ?`, [rowId])) as any[])
      .map((c) => Number(c.auditor_id)).filter((i) => i && i !== leadId);
    const names = await this.loadUserNames([leadId, ...coIds].filter(Boolean));

    const addrParts = [pick(company, 'address'), pick(company, 'city'), pick(company, 'country_name')]
      .filter((p, i, a) => p && !a.slice(0, i).some((q) => q.toLowerCase().includes(p.toLowerCase())));
    const emails = Array.from(new Set(
      [pick(company, 'email'), pick(req, 'auditee_email')].flatMap((e) => e.split(/[;,]/)).map((e) => e.trim()).filter(Boolean),
    )).join(' ; ');

    const fields: Record<string, string> = {
      name: pick(company, 'name'),
      address: addrParts.join(', '),
      site_address: pick(req, 'company_location') && pick(req, 'company_branch') !== 'Head Office' ? pick(req, 'company_location') : '',
      employees: '',
      emails,
      contact_person: pick(company, 'contact_person') || pick(req, 'auditee_name'),
      telephone: [pick(company, 'mobile'), pick(company, 'telephone')].filter(Boolean).join(' / '),
      email: '',
      multisite: '',
      scope: pick(cert, 'scope_of_work') || pick(req, 'scope_of_work') || pick(company, 'scope_of_work'),
      cert_no: pick(cert, 'certificate_no') || pick(req, 'previous_cert_no'),
      ea_code: pick(cert, 'ea_codes'),
      accreditation: pick(row, 'accreditation') || pick(req, 'accreditation') || pick(company, 'accreditation'),
      lead_auditor: (leadId && names.get(leadId)) || '',
      auditors: coIds.map((i) => names.get(i)).filter(Boolean).join(', '),
      technical_expert: '',
      man_days: '',
      audit_date: toDMY(row.schedule_date),
    };

    let auditees: { name: string; designation: string }[] = [];
    try {
      const raw = req?.auditees;
      const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
      if (Array.isArray(arr)) {
        auditees = arr.map((a: any) => ({ name: s(a?.name), designation: s(a?.designation) })).filter((a) => a.name);
      }
    } catch { /* leave empty */ }
    if (!auditees.length && pick(req, 'auditee_name')) {
      auditees = [{ name: pick(req, 'auditee_name'), designation: '' }];
    }

    return {
      row,
      company,
      standard_name: s(std.name),
      standard_title: s(std.title),
      stage_guess: this.inferStage(s(row.audit_type), s(row.audit_stage)),
      fields,
      auditees,
    };
  }

  /** audit_type INITIAL + audit_stage "Stage 1" -> stage1; other INITIAL -> stage2; SURVEILLANCE / RECERTIFICATION by type. */
  private inferStage(auditType: string, auditStage: string): StageType | null {
    const t = s(auditType).toUpperCase();
    if (t === 'SURVEILLANCE') return 'surveillance';
    if (t === 'RECERTIFICATION') return 'recert';
    if (t === 'INITIAL') return /1/.test(s(auditStage)) ? 'stage1' : 'stage2';
    return null;
  }

  private async resolveTemplate(standardId: number, stage: StageType): Promise<ReportTemplate | null> {
    const all = await this.templates.find({ where: { is_active: true } });
    const fit = all
      .filter((t) => (t.standard_id === null || t.standard_id === standardId) && (t.stage_type === null || t.stage_type === stage))
      .sort((a, b) => (b.standard_id ? 2 : 0) + (b.stage_type ? 1 : 0) - ((a.standard_id ? 2 : 0) + (a.stage_type ? 1 : 0)));
    return fit[0] ?? null;
  }

  // ═════════════════════════════ CREATE ═════════════════════════════

  /** Creates the report (or returns the one that already exists for this audit + standard). */
  /** Clause rows for one standard: its own list, else another booked standard's list, else the first list that exists. */
  private async libraryFor(standardId: number, stage: StageType, booked: { id: number }[]) {
    const scope = LIBRARY_SCOPE[stage];
    const q = (id: number) => this.library.find({ where: { standard_id: id, scope, is_active: true }, order: { sort_order: 'ASC', id: 'ASC' } });
    let lib = await q(standardId);
    if (!lib.length) {
      for (const st of booked) {
        if (st.id === standardId) continue;
        lib = await q(st.id);
        if (lib.length) break;
      }
    }
    if (!lib.length) {
      const anyRows = await this.library.find({ where: { scope, is_active: true }, order: { standard_id: 'ASC', sort_order: 'ASC', id: 'ASC' } });
      if (anyRows.length) lib = anyRows.filter((x) => x.standard_id === anyRows[0].standard_id);
    }
    return lib;
  }

  /**
   * Creates the report (or returns the one that already exists).
   *  - one report per standard of the audit            -> { standard_id }
   *  - ONE combined report covering all its standards  -> { combined: true }   (stored with standard_id = 0)
   */
  async create(dto: CreateReportDto, userId: number) {
    const allowed = await this.rowStandards(dto.audit_schedule_row_id);
    const combined = !!dto.combined;
    if (combined && allowed.length < 2) {
      throw new BadRequestException('A combined report needs two or more standards booked for this audit.');
    }
    if (!combined && !dto.standard_id) {
      throw new BadRequestException('Choose a standard, or create the combined report.');
    }
    const stdId = combined ? 0 : Number(dto.standard_id);

    const existing = await this.reports.findOne({ where: { audit_schedule_row_id: dto.audit_schedule_row_id, standard_id: stdId } });
    if (existing) {
      await this.assertAccess(existing, userId);
      return this.getEditorModel(existing.id, userId);
    }

    if (!combined && allowed.length && !allowed.some((x) => x.id === stdId)) {
      throw new BadRequestException(
        `Standard ${stdId} is not booked for this audit. Booked: ${allowed.map((x) => `${x.id} (${x.name})`).join(', ')}.`,
      );
    }
    // Combined report: standards the auditor chose NOT to show on this report (create screen dropdown).
    const hidden = combined ? this.sanitizeHidden(dto.exclude_standard_ids, allowed) : [];
    if (combined && hidden.length) {
      const unknown = hidden.filter((h) => !allowed.some((x) => x.id === h));
      if (unknown.length) throw new BadRequestException(`Standard ${unknown.join(', ')} is not booked for this audit, so it cannot be removed from the report.`);
      if (allowed.length - hidden.length < 2) {
        throw new BadRequestException('A combined report must still show two or more standards. For a single standard create its separate report instead.');
      }
    }
    const covered = combined ? allowed.filter((x) => !hidden.includes(x.id)) : allowed.filter((x) => x.id === stdId);
    const primaryId = combined ? covered[0].id : stdId;
    const crm = await this.loadCrm(dto.audit_schedule_row_id, primaryId);
    const stage = dto.stage_type ?? crm.stage_guess;
    if (!stage) {
      throw new BadRequestException(
        'Could not tell the stage from this audit. Send stage_type: stage1, stage2, surveillance or recert.',
      );
    }

    // Clause rows. Combined report: every standard's list, each group headed with the standard name —
    // unless the covered standards form an IMS and the IMS clause list exists (standards.id 1): then that one list is used.
    type LibRow = { group_key: any; code: string; heading: string; title: string; requirement: string | null; default_evidence: string | null };
    const lib: LibRow[] = [];
    const stdTextEarly = combined ? covered.map((x) => x.name).join(' / ') : crm.standard_name;
    const formProfile = profileFor(stdTextEarly);
    let formStdId: number | null = null; // standards.id whose template rows the combined report uses
    if (combined && formProfile.library_standard_id) {
      const own = await this.library.find({ where: { standard_id: formProfile.library_standard_id, scope: LIBRARY_SCOPE[stage], is_active: true }, order: { sort_order: 'ASC', id: 'ASC' } });
      if (own.length) {
        formStdId = formProfile.library_standard_id;
        own.forEach((l) => lib.push({ group_key: l.group_key, code: l.code, heading: l.heading, title: l.title, requirement: l.requirement, default_evidence: l.default_evidence }));
      }
    }
    if (lib.length) {
      // IMS list already loaded
    } else if (combined) {
      for (const st of covered) {
        (await this.libraryFor(st.id, stage, allowed)).forEach((l) =>
          lib.push({ group_key: l.group_key, code: l.code, heading: `${st.name} — ${l.heading}`, title: l.title, requirement: l.requirement, default_evidence: l.default_evidence }),
        );
      }
    } else {
      (await this.libraryFor(stdId, stage, allowed)).forEach((l) =>
        lib.push({ group_key: l.group_key, code: l.code, heading: l.heading, title: l.title, requirement: l.requirement, default_evidence: l.default_evidence }),
      );
    }
    if (!lib.length) {
      throw new BadRequestException(
        `The clause library is empty for this standard (${LIBRARY_SCOPE[stage]} list). Seed it first: POST /audit-reports/clause-library/seed/<iso9001|iso14001> or import it with POST /audit-reports/clause-library/import.`,
      );
    }

    const tpl = await this.resolveTemplate(formStdId ?? primaryId, stage);
    const stdText = stdTextEarly;

    // Carry the "brief about the organization" over from the last approved report of this company + standard.
    const [prev] = await this.db.query(
      `SELECT s.body FROM audit_report_sections s
         INNER JOIN audit_reports r ON r.id = s.report_id
        WHERE r.company_id = ? AND r.standard_id = ? AND r.status IN ('approved','locked')
          AND s.\`key\` = 'org_brief' AND s.body IS NOT NULL AND s.body <> ''
        ORDER BY r.approved_at DESC LIMIT 1`,
      [crm.row.company_id, stdId],
    );

    const saved = await this.reports.manager.transaction(async (m) => {
      const report = await m.save(
        m.create(AuditReport, {
          audit_schedule_row_id: dto.audit_schedule_row_id,
          company_id: Number(crm.row.company_id),
          standard_id: stdId,
          template_id: tpl?.id ?? null,
          stage_type: stage,
          status: 'draft',
          crm_json: { fields: crm.fields, standard_name: stdText, standard_title: combined ? '' : crm.standard_title, all_standards: covered.map((x) => ({ id: x.id, name: x.name, title: x.title })), booked_standards: allowed.map((x) => ({ id: x.id, name: x.name, title: x.title })), combined, refreshed_at: new Date().toISOString() },
          edits_json: {},
          extras_json: { declarations: [], attachments: [], mark_use: null, mark_use_nc_no: '', change_detail: {}, hidden_standards: hidden },
          created_by: userId,
        }),
      );

      report.report_no = `${REPORT_NO_PREFIX[stage]}-${new Date().getFullYear()}-${String(report.id).padStart(6, '0')}`;
      await m.save(report);

      await m.save(
        lib.map((l, i) =>
          m.create(AuditReportClause, {
            report_id: report.id,
            group_key: l.group_key,
            code: l.code,
            heading: l.heading,
            title: l.title,
            requirement: l.requirement,
            evidence_text: null,                          // the auditor's own text; the standard wording is kept below and appears when the row is C
            default_evidence: l.default_evidence || null,
            sort_order: i,
          }),
        ),
      );

      await m.save(
        SECTION_DEFS.filter((d) => sectionAppliesTo(d.key, stage)).map((d) =>
          m.create(AuditReportSection, {
            report_id: report.id,
            key: d.key,
            body: d.key === 'org_brief'
              ? (prev?.body ?? '')
              : defaultSectionBody(d.key, stage, stdText),
          }),
        ),
      );

      if (crm.auditees.length) {
        await m.save(
          crm.auditees.map((a, i) =>
            m.create(AuditReportAttendance, { report_id: report.id, name: a.name, designation: a.designation || null, sort_order: i }),
          ),
        );
      }
      return report;
    });

    this.logger.log(`[REPORT] created #${saved.id} ${saved.report_no} (audit ${dto.audit_schedule_row_id}, ${combined ? 'COMBINED' : `std ${stdId}`}, ${stage})`);
    return this.getEditorModel(saved.id, userId);
  }

  // ═════════════════════════════ EDIT ═════════════════════════════

  async updateHeader(id: number, dto: UpdateHeaderDto, userId: number) {
    const r = await this.loadForWrite(id, userId);

    if (dto.fields) {
      const edits = { ...(r.edits_json ?? {}) };
      for (const [k, v] of Object.entries(dto.fields)) {
        if (!(EDITABLE_FIELDS as readonly string[]).includes(k)) throw new BadRequestException(`Unknown header field "${k}"`);
        if (v === null) delete edits[k];
        else edits[k] = String(v);
      }
      r.edits_json = edits;
    }
    if (dto.extras) {
      if ('hidden_standards' in dto.extras) {
        // Combined report: which booked standards are left off the printed report. Only booked ids; at least one must stay visible.
        const booked: { id: number }[] = Array.isArray(r.crm_json?.booked_standards) ? r.crm_json.booked_standards
          : Array.isArray(r.crm_json?.all_standards) ? r.crm_json.all_standards : [];
        const hidden = r.standard_id === 0 ? this.sanitizeHidden(dto.extras.hidden_standards, booked) : [];
        if (booked.length && booked.filter((b) => !hidden.includes(Number(b.id))).length < 1) {
          throw new BadRequestException('At least one standard must stay on the report.');
        }
        dto.extras = { ...dto.extras, hidden_standards: hidden };
      }
      if ('disabled_clauses' in dto.extras) {
        // 2026-11-11: clause rows the auditor switched OFF (Enable / Disable in the Clauses panel) are left off the printed report.
        // Default = every row enabled; only ids of this report's own clause rows are kept.
        const own = new Set((await this.clauses.find({ where: { report_id: r.id }, select: ['id'] })).map((c) => c.id));
        const list = Array.isArray(dto.extras.disabled_clauses) ? dto.extras.disabled_clauses : [];
        const disabled = Array.from(new Set(list.map(Number).filter((v: number) => own.has(v))));
        dto.extras = { ...dto.extras, disabled_clauses: disabled };
      }
      r.extras_json = { ...(r.extras_json ?? {}), ...dto.extras };
    }
    if (dto.conclusion !== undefined) r.conclusion = dto.conclusion;
    if (dto.recommendation !== undefined) r.recommendation = dto.recommendation;
    if (dto.next_audit_date !== undefined) r.next_audit_date = dto.next_audit_date;

    await this.reports.save(r);
    return { ok: true };
  }

  private async clauseOrFail(reportId: number, clauseId: number) {
    const c = await this.clauses.findOne({ where: { id: clauseId, report_id: reportId } });
    if (!c) throw new NotFoundException(`Clause row ${clauseId} not found on report ${reportId}`);
    return c;
  }

  async upsertClause(reportId: number, clauseId: number, dto: UpsertClauseDto, userId: number) {
    await this.loadForWrite(reportId, userId);
    const c = await this.clauseOrFail(reportId, clauseId);
    if (dto.status !== undefined) c.status = dto.status;
    if (dto.evidence_text !== undefined) c.evidence_text = dto.evidence_text;
    if (dto.docs_reviewed !== undefined) c.docs_reviewed = dto.docs_reviewed;
    return this.clauses.save(c);
  }

  /** Save many rows at once (the editor's "save page" button). Items are matched by clause row id. */
  async bulkClauses(reportId: number, dto: BulkClausesDto, userId: number) {
    await this.loadForWrite(reportId, userId);
    const rows = await this.clauses.find({ where: { report_id: reportId } });
    const byId = new Map(rows.map((r) => [r.id, r]));
    const touched: AuditReportClause[] = [];
    for (const it of dto.items as any[]) {
      const row = byId.get(Number(it.id));
      if (!row) throw new BadRequestException(`Clause row ${it.id} does not belong to report ${reportId}`);
      if (it.status !== undefined) row.status = it.status;
      if (it.evidence_text !== undefined) row.evidence_text = it.evidence_text;
      if (it.docs_reviewed !== undefined) row.docs_reviewed = it.docs_reviewed;
      touched.push(row);
    }
    await this.clauses.save(touched);
    return { ok: true, saved: touched.length };
  }

  async upsertSection(reportId: number, key: string, body: string, userId: number) {
    const r = await this.loadForWrite(reportId, userId);
    if (!sectionAppliesTo(key, r.stage_type)) throw new BadRequestException(`Section "${key}" is not used on a ${r.stage_type} report`);
    let sec = await this.sections.findOne({ where: { report_id: reportId, key } });
    if (!sec) sec = this.sections.create({ report_id: reportId, key });
    sec.body = body;
    await this.sections.save(sec);
    return { ok: true };
  }

  async setAttendance(reportId: number, attendees: AttendeeDto[], userId: number) {
    await this.loadForWrite(reportId, userId);
    await this.reports.manager.transaction(async (m) => {
      await m.delete(AuditReportAttendance, { report_id: reportId });
      if (attendees.length) {
        await m.save(
          attendees.map((a, i) =>
            m.create(AuditReportAttendance, {
              report_id: reportId, name: a.name.trim(), designation: a.designation?.trim() || null,
              signature_path: a.signature_path || null, sort_order: i,
            }),
          ),
        );
      }
    });
    return { ok: true, count: attendees.length };
  }

  /**
   * Re-read the CRM and replace crm_json. Auditor overrides (edits_json) and every typed finding are left alone.
   * Returns which CRM-sourced fields changed so the UI can tell the auditor.
   */
  async refreshFromCrm(reportId: number, userId: number) {
    const r = await this.loadForWrite(reportId, userId);
    const booked = await this.rowStandards(r.audit_schedule_row_id);
    const combined = r.standard_id === 0;
    const crm = await this.loadCrm(r.audit_schedule_row_id, combined ? (booked[0]?.id ?? 0) : r.standard_id);
    const before = r.crm_json?.fields ?? {};
    const edits = r.edits_json ?? {};
    const changed = Object.keys(crm.fields)
      .filter((k) => s(before[k]) !== s(crm.fields[k]))
      .map((k) => ({ field: k, from: s(before[k]), to: s(crm.fields[k]), overridden_by_auditor: k in edits }));
    const hidden = combined ? this.sanitizeHidden(r.extras_json?.hidden_standards, booked) : [];
    const covered = combined ? booked.filter((x) => !hidden.includes(x.id)) : booked.filter((x) => x.id === r.standard_id);
    r.crm_json = {
      fields: crm.fields,
      standard_name: combined ? covered.map((x) => x.name).join(' / ') : crm.standard_name,
      standard_title: combined ? '' : crm.standard_title,
      all_standards: covered.map((x) => ({ id: x.id, name: x.name, title: x.title })),
      booked_standards: booked.map((x) => ({ id: x.id, name: x.name, title: x.title })),
      combined,
      refreshed_at: new Date().toISOString(),
    };
    await this.reports.save(r);
    return { ok: true, changed };
  }

  /**
   * Raise an NC / observation from a clause row using the existing NC module (nc__ncs + ncr_entries),
   * then link the new entry back to the clause so the report and the NC tracker stay in step.
   */
  async raiseNc(reportId: number, clauseId: number, dto: RaiseNcDto, userId: number) {
    const r = await this.loadForWrite(reportId, userId);
    const c = await this.clauseOrFail(reportId, clauseId);
    if (c.ncr_entry_id) throw new ConflictException('An NC has already been raised from this clause row.');

    const statement = s(dto.statement) || s(c.evidence_text);
    if (!statement) throw new BadRequestException('Write the finding in the evidence box (or send a statement) before raising an NC.');

    const clauseText = c.group_key === 'clause' ? `${c.title || c.code}` : c.title;
    const ncType = dto.nc_type;

    // 1. Reuse an open NC for this audit in scheme_dbs, if one exists.
    const [existing] = await this.db.query(
      `SELECT id FROM nc__ncs WHERE audit_id = ? AND status <> 'closed' ORDER BY id ASC LIMIT 1`,
      [r.audit_schedule_row_id],
    );

    let ncId: number;
    if (existing?.id) {
      ncId = Number(existing.id);
    } else {
      // 2. No open NC yet: create the NC header in scheme_dbs.
      const att = await this.attendance.find({ where: { report_id: reportId }, order: { sort_order: 'ASC' } });
      const crm = r.crm_json?.fields ?? {};
      const [auditRow] = await this.db.query(
        `SELECT company_id, audit_type FROM audit_schedule_rows WHERE id = ? LIMIT 1`,
        [r.audit_schedule_row_id],
      );
      const hdr = await this.db.query(
        `INSERT INTO nc__ncs
           (audit_id, company_id, auditee_name, auditees, audit_type, nc_type, status,
            created_by, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, 'open', ?, NOW(), NOW())`,
        [
          r.audit_schedule_row_id,
          auditRow?.company_id ?? null,
          s(r.edits_json?.contact_person) || s(crm.contact_person) || null,
          JSON.stringify(att.map((a) => ({ name: a.name, designation: a.designation || '' }))),
          auditRow?.audit_type ?? null,
          ncType,
          userId,
        ],
      );
      ncId = Number(hdr.insertId);
    }

    // 3. Add this clause's finding to the NC.
    const ins = await this.db.query(
      `INSERT INTO ncr_entries
         (nc_id, nc_type, ncr_statement, criteria_clause, corrective_action, status, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, 'open', NOW(), NOW())`,
      [ncId, ncType, statement, clauseText, s(dto.corrective_action) || null],
    );
    const entryId = Number(ins.insertId) || null;
    
    c.nc_id = ncId;
    c.ncr_entry_id = entryId;
    if (ncType === 'Observation') { if (c.status !== 'NC') c.status = 'OFI'; } else c.status = 'NC';
    if (!c.evidence_text) c.evidence_text = statement;
    await this.clauses.save(c);
    return { ok: true, nc_id: ncId, ncr_entry_id: entryId, reused_existing_nc: !!existing?.id };
  }

  // ═════════════════════════════ READ ═════════════════════════════

  async list(q: { status?: string; stage_type?: string; standard_id?: number; company_id?: number; search?: string; page?: number; limit?: number }, userId: number) {
    const all = await this.canViewAll(userId);
    const where: string[] = ['1=1'];
    const params: any[] = [];
    if (!all) { where.push('r.created_by = ?'); params.push(userId); }
    if (q.status) { where.push('r.status = ?'); params.push(q.status); }
    if (q.stage_type) { where.push('r.stage_type = ?'); params.push(q.stage_type); }
    if (q.standard_id) { where.push('r.standard_id = ?'); params.push(q.standard_id); }
    if (q.company_id) { where.push('r.company_id = ?'); params.push(q.company_id); }
    if (q.search) { where.push('(c.name LIKE ? OR r.report_no LIKE ?)'); params.push(`%${q.search}%`, `%${q.search}%`); }

    const limit = Math.min(Math.max(Number(q.limit) || 25, 1), 200);
    const page = Math.max(Number(q.page) || 1, 1);

    const [{ total }] = await this.db.query(
      `SELECT COUNT(*) AS total FROM audit_reports r LEFT JOIN companies c ON c.id = r.company_id WHERE ${where.join(' AND ')}`,
      params,
    );
    const rows = await this.db.query(
      `SELECT r.id, r.report_no, r.audit_schedule_row_id, r.company_id, c.name AS company_name, r.standard_id,
              r.stage_type, r.status, r.conclusion, r.version, r.pdf_path IS NOT NULL AS has_pdf,
              r.created_by, r.submitted_at, r.approved_at, r.updated_at
         FROM audit_reports r LEFT JOIN companies c ON c.id = r.company_id
        WHERE ${where.join(' AND ')} ORDER BY r.updated_at DESC LIMIT ? OFFSET ?`,
      [...params, limit, (page - 1) * limit],
    );
    return { rows, total: Number(total), page, limit, totalPages: Math.ceil(Number(total) / limit) };
  }

  /** For the audit tracker: which reports exist for one audit row and where they stand. */
  async byAudit(rowId: number, userId: number) {
    const rows = await this.reports.find({ where: { audit_schedule_row_id: rowId } });
    const out: any[] = [];
    for (const r of rows) {
      try {
        await this.assertAccess(r, userId);
        out.push({
          id: r.id, report_no: r.report_no, standard_id: r.standard_id, stage_type: r.stage_type,
          status: r.status, version: r.version, has_pdf: !!r.pdf_path, approved_at: r.approved_at,
        });
      } catch { /* not visible to this user */ }
    }
    return out;
  }

  /** Approved client documents from the checklist module, so the auditor can tick them into "documents reviewed". */
  private async clientDocuments(rowId: number, standardId: number) {
    try {
      return await this.db.query(
        `SELECT i.id, i.document_original_name AS file_name, t.item_text, t.category
           FROM audit_checklist_items i
           INNER JOIN audit_checklists c ON c.id = i.audit_checklist_id
           INNER JOIN checklist_template_items t ON t.id = i.template_item_id
          WHERE c.audit_schedule_row_id = ? AND (? = 0 OR c.standard_id = ?) AND i.status = 'approved'
            AND i.document_original_name IS NOT NULL
          ORDER BY t.sort_order, i.id`,
        [rowId, standardId, standardId],
      );
    } catch (e: any) {
      this.logger.warn(`[REPORT] client documents lookup skipped: ${e.message}`);
      return [];
    }
  }

  /** NC / observation entries recorded against this audit (NC module). */
  async loadNcs(rowId: number) {
    try {
      return await this.db.query(
        `SELECT e.id AS entry_id, e.nc_id, e.nc_type, e.ncr_statement, e.criteria_clause, e.corrective_action, e.status
           FROM ncr_entries e INNER JOIN nc__ncs n ON n.id = e.nc_id
          WHERE n.audit_id = ? ORDER BY e.id`,
        [rowId],
      );
    } catch (e: any) {
      this.logger.warn(`[REPORT] NC lookup skipped: ${e.message}`);
      return [];
    }
  }

  private mergedFields(r: AuditReport): Record<string, string> {
    const out: Record<string, string> = { ...(r.crm_json?.fields ?? {}) };
    for (const [k, v] of Object.entries(r.edits_json ?? {})) if (s(v) !== '' || k in out) out[k] = s(v);
    return out;
  }

  /** Everything the editor screen needs in one call. */
  async getEditorModel(id: number, userId: number) {
    const r = await this.getReportOrFail(id);
    await this.assertAccess(r, userId);
    await this.ensureStandardIds(r);

    const [clauses, sections, attendance, docs, ncs, canApprove] = await Promise.all([
      this.clauses.find({ where: { report_id: id }, order: { sort_order: 'ASC', id: 'ASC' } }),
      this.sections.find({ where: { report_id: id } }),
      this.attendance.find({ where: { report_id: id }, order: { sort_order: 'ASC', id: 'ASC' } }),
      this.clientDocuments(r.audit_schedule_row_id, r.standard_id),
      this.loadNcs(r.audit_schedule_row_id),
      this.canApprove(userId, r.company_id),
    ]);

    const stdForDefault = s(r.crm_json?.standard_name);
    const secMap = new Map(sections.map((x) => [x.key, x.body ?? '']));
    const bodyOrDefault = (key: string): string =>
      secMap.get(key) || (key === 'effectiveness'
        ? defaultSectionBody(key, r.stage_type, stdForDefault)
        : '');
    return {
      report: {
        id: r.id, report_no: r.report_no, audit_schedule_row_id: r.audit_schedule_row_id, company_id: r.company_id,
        standard_id: r.standard_id, is_combined: r.standard_id === 0,
        standard_label: r.standard_id === 0 ? `Combined: ${s(r.crm_json?.standard_name)}` : `${s(r.crm_json?.standard_name)} ${s(r.crm_json?.standard_title)}`.trim(),
        // combined report: every booked standard (id, name, title); extras.hidden_standards lists the ids left off the print
        all_standards: this.standardsForEditor(r),
        stage_type: r.stage_type, stage_label: STAGE_LABEL[r.stage_type], status: r.status,
        conclusion: r.conclusion, recommendation: r.recommendation, next_audit_date: r.next_audit_date,
        version: r.version, return_reason: r.return_reason, has_pdf: !!r.pdf_path,
        submitted_at: r.submitted_at, approved_at: r.approved_at,
        crm_refreshed_at: r.crm_json?.refreshed_at ?? null,
      },
      fields: this.mergedFields(r),
      crm_fields: r.crm_json?.fields ?? {},
      overridden: Object.keys(r.edits_json ?? {}),
      extras: r.extras_json ?? {},
      sections: SECTION_DEFS.filter((d) => sectionAppliesTo(d.key, r.stage_type)).map((d) => ({
        key: d.key, label: d.label, body: bodyOrDefault(d.key),
      })),
      clauses,
      attendance,
      client_documents: docs,
      ncs,
      options: {
        conclusion: CONCLUSION_OPTIONS,
        stage1_recommendation: STAGE1_RECOMMENDATION_OPTIONS,
        declarations: DECLARATIONS,
        attachments: profileFor(stdForDefault).attachments,
        mark_use: MARK_USE_OPTIONS,
        change_detail: CHANGE_DETAIL_QUESTIONS,
        editable_fields: EDITABLE_FIELDS,
      },
      permissions: {
        can_edit: EDITABLE_STATUSES.includes(r.status),
        can_approve: canApprove && r.status === 'submitted',
      },
    };
  }

  // ═════════════════════════════ VIEW MODEL (what the template prints) ═════════════════════════════

  /**
   * Builds the data the Handlebars template prints. Pure function of the database rows.
   * Approved/locked reports do NOT use this live: they render from snapshot_json (see ReportRenderService).
   */
  async buildViewModel(id: number) {
    const r = await this.getReportOrFail(id);
    const [allClauses, sections, attendance, ncs, tpl] = await Promise.all([
      this.clauses.find({ where: { report_id: id }, order: { sort_order: 'ASC', id: 'ASC' } }),
      this.sections.find({ where: { report_id: id } }),
      this.attendance.find({ where: { report_id: id }, order: { sort_order: 'ASC', id: 'ASC' } }),
      this.loadNcs(r.audit_schedule_row_id),
      r.template_id ? this.templates.findOne({ where: { id: r.template_id } }) : Promise.resolve(null),
    ]);

    const f = this.mergedFields(r);
    const x = r.extras_json ?? {};
    // clause rows switched OFF by the auditor (extras.disabled_clauses) are not printed (Word, PDF, HTML preview)
    const disabledIds = new Set<number>((Array.isArray(x.disabled_clauses) ? x.disabled_clauses : []).map(Number));
    const clauses = allClauses.filter((c) => !disabledIds.has(c.id));
    const isStage1 = r.stage_type === 'stage1';
    const stdBase = s(r.crm_json?.standard_name);
    const stdTitle = s(r.crm_json?.standard_title);
    const allStdRaw: { id?: number; name: string; title: string }[] = Array.isArray(r.crm_json?.all_standards) ? r.crm_json.all_standards : [];
    // Combined report: standards the auditor removed from the print (extras.hidden_standards) are left off cover, title and wording.
    const hiddenIds = this.sanitizeHidden(x.hidden_standards, allStdRaw.filter((z) => z.id != null) as { id: number }[]);
    const allStdVisible = hiddenIds.length ? allStdRaw.filter((z) => z.id == null || !hiddenIds.includes(Number(z.id))) : allStdRaw;
    const allStd = allStdVisible.length ? allStdVisible : allStdRaw;
    const multiStd = allStd.length > 1;
    const stdName = multiStd ? allStd.map((x) => s(x.name)).join(' / ') : `${stdBase} ${stdTitle}`.trim();
    // wording ("{STANDARDS}") follows the visible standards; the FORM (IMS / single) keeps following the report as created
    const stdBaseLabels = allStdRaw.length > 1 && hiddenIds.length ? allStd.map((z) => s(z.name)).join(' / ') : stdBase;
    // plain copy (formatting markers removed); the formatted copy is sections_rich below
    const sec = (k: string) => paragraphs(stripRich(sections.find((z) => z.key === k)?.body));
    const rawSec = (k: string) => sections.find((z) => z.key === k)?.body ?? '';
    const effBody = rawSec('effectiveness') || (isStage1
      ? ''
      : defaultSectionBody('effectiveness', r.stage_type, stdBase));
    const profile = profileFor(stdBase); // Form 04-2a (9001) / Form 04-2b (14001) / IMS / generic wording
    const stdNames = multiStd ? allStd.map((z) => s(z.name)) : standardsOf(stdBase);
    const titleInfo = profile.title ? profile.title(stdNames, r.stage_type) : null;
    const imsColumns = profile.theme.cover.kind === 'ims'
      ? (multiStd && profile.cover_column ? allStd.map((z) => profile.cover_column!(s(z.name), s(z.title))) : profile.cover_columns_default)
      : [];
    const tick = (list: string[] | undefined, key: string) => Array.isArray(list) && list.includes(key);

    const cd = x.change_detail ?? {};
    const changeDetail = CHANGE_DETAIL_QUESTIONS.map((q) => {
      const a = cd[q.key];
      const value = s(typeof a === 'object' && a ? a.value : a).toLowerCase();
      const note = s(typeof a === 'object' && a ? a.note : '');
      return q.kind === 'yesno'
        ? { label: q.label, yesno: true, yes: value === 'yes', no: value === 'no' }
        : { label: q.label, yesno: false, none: value === 'none', yes: value === 'yes', note };
    });

    // NC module: statement + running number of every finding recorded for this audit (NC#n for Major/Minor, OFI#n for observations)
    const ncByEntry = new Map<number, any>((ncs as any[]).map((n) => [n.entry_id, n]));
    const ncPos = new Map<number, string>();
    let ncCount = 0;
    let ofiCount = 0;
    (ncs as any[]).forEach((n) => {
      if (/observation/i.test(s(n.nc_type))) { ofiCount += 1; ncPos.set(n.entry_id, `OFI#${ofiCount}`); }
      else { ncCount += 1; ncPos.set(n.entry_id, `NC#${ncCount}`); }
    });
    /**
     *  C       -> the auditor's text, or the original standard wording stored in the database
     *  NC/OFI  -> the finding text from the NC module + bold "(NC#n – Clause x)" / "(OFI#n – Clause x)"
     *  then    -> "Documents Reviewed:" with the ticked client documents as bullets (like the Word form)
     */
    const evidenceOf = (c: AuditReportClause): { evidence: RichBlock[]; nc_ref: string; docs: string[] } => {
      let blocks: RichBlock[] = [];
      let ref = '';
      if ((c.status === 'NC' || c.status === 'OFI') && c.ncr_entry_id && ncByEntry.has(c.ncr_entry_id)) {
        const n = ncByEntry.get(c.ncr_entry_id);
        const clauseNo = s(n.criteria_clause).split('\n')[0] || c.code;
        blocks = parseRich(n.ncr_statement).length ? parseRich(n.ncr_statement) : parseRich(c.evidence_text);
        ref = `(${ncPos.get(c.ncr_entry_id)} – Clause ${clauseNo})`;
      } else {
        blocks = parseRich(c.evidence_text);
        if (c.status === 'C' && !blocks.length) blocks = parseRich(c.default_evidence);
      }
      if (ref) {
        if (blocks.length) blocks[blocks.length - 1].runs.push({ t: ' ' }, { t: ref, b: true });
        else blocks = [{ bullet: false, runs: [{ t: ref, b: true }] }];
      }
      const docs = Array.isArray(c.docs_reviewed) ? c.docs_reviewed : [];
      if (docs.length) {
        blocks.push({ bullet: false, runs: [{ t: 'Documents Reviewed:' }] });
        docs.forEach((d) => blocks.push({ bullet: true, runs: [{ t: d }] }));
      }
      return { evidence: blocks, nc_ref: '', docs: [] };
    };

    // photos the auditor uploaded (references only; the file is read when the PDF / Word file is made)
    const imgRows = await this.images.find({ where: { report_id: id }, order: { sort_order: 'ASC', id: 'ASC' } });
    const imgRef = (i: AuditReportImage) => ({ id: i.id, file: i.file_name, caption: i.caption || '', w: i.width, h: i.height, mime: i.mime });
    const imgBySection: Record<string, any[]> = { org_brief: [], audit_brief: [] };
    const imgByClause = new Map<number, any[]>();
    imgRows.forEach((i) => {
      if (i.clause_id) imgByClause.set(i.clause_id, [...(imgByClause.get(i.clause_id) ?? []), imgRef(i)]);
      else (imgBySection[i.section_key] ??= []).push(imgRef(i));
    });

    const rowOf = (c: AuditReportClause) => ({
      id: c.id,
      label: c.group_key === 'clause' ? (c.title || c.code) : c.title,
      code: c.code,
      requirement: s(c.requirement),
      C: c.status === 'C', NC: c.status === 'NC', OFI: c.status === 'OFI', NA: c.status === 'NA',
      ...evidenceOf(c),
      images: imgByClause.get(c.id) ?? [],
      docs: Array.isArray(c.docs_reviewed) ? c.docs_reviewed : [],
    });
    const groupOf = (key: string) => {
      const out: { heading: string; rows: ReturnType<typeof rowOf>[] }[] = [];
      for (const c of clauses.filter((z) => z.group_key === key)) {
        let g = out.find((o) => o.heading === c.heading);
        if (!g) { g = { heading: c.heading, rows: [] }; out.push(g); }
        g.rows.push(rowOf(c));
      }
      return out;
    };

    // NC tables
    const ncRows = (ncs as any[]).map((n, i) => ({
      no: i + 1,
      clause: s(n.criteria_clause).split('\n')[0],
      type: s(n.nc_type),
      statement: paragraphs(n.ncr_statement),
      corrective_action: s(n.corrective_action),
    }));
    const byClause = new Map<string, { major: number; minor: number; obs: number }>();
    for (const n of ncRows) {
      const k = n.clause || 'Not specified';
      const e = byClause.get(k) ?? { major: 0, minor: 0, obs: 0 };
      if (/major/i.test(n.type)) e.major += 1; else if (/minor/i.test(n.type)) e.minor += 1; else e.obs += 1;
      byClause.set(k, e);
    }
    // "Category of Audit Findings": a profile may carry a fixed component list (ISO 45001); otherwise one row per clause with a finding
    let ncCounts: { no: number; component: string; major: number; minor: number; obs: number }[];
    if (profile.finding_components) {
      const hit = (clause: string, list: string[]) => list.some((p) => clause === p || clause.startsWith(p + '.'));
      const used = new Set<string>();
      ncCounts = profile.finding_components.map((fc, i) => {
        const e = { no: i + 1, component: fc.label, major: 0, minor: 0, obs: 0 };
        for (const [k, v] of byClause.entries()) {
          if (hit(k, fc.clauses)) { e.major += v.major; e.minor += v.minor; e.obs += v.obs; used.add(k); }
        }
        return e;
      });
      for (const [k, v] of byClause.entries()) {
        if (!used.has(k)) ncCounts.push({ no: ncCounts.length + 1, component: `Other (${k})`, ...v });
      }
    } else {
      ncCounts = Array.from(byClause.entries()).map(([component, v], i) => ({ no: i + 1, component, ...v }));
    }
    const totals = ncCounts.reduce((a, c) => ({ major: a.major + c.major, minor: a.minor + c.minor, obs: a.obs + c.obs }), { major: 0, minor: 0, obs: 0 });

    const accreditation = s(f.accreditation);
    const nums = isStage1
      ? { org: 1, team: 2, change: 3, cats: 4, disc: 5, brief: 6, attend: 7, auditBrief: 8, checklist: 9, summary: 10, decl: 11, rec: 12 }
      : { org: 1, team: 2, change: 3, cats: 4, disc: 5, brief: 6, attend: 7, auditBrief: 8, checklist: 9, perf: 10, risk: 11, feedback: 12, statutory: 13, mark: 14, effectiveness: 15, summary: 16, decl: 17 };

    return {
      meta: {
        report_no: s(r.report_no), stage_type: r.stage_type, stage_label: STAGE_LABEL[r.stage_type], is_stage1: isStage1,
        status: r.status, draft: !['approved', 'locked'].includes(r.status), version: r.version,
        standard_name: stdName, form_no: tpl?.form_no ?? 'Form 04-2a', issue: tpl?.issue ?? '01', rev: tpl?.rev ?? (isStage1 ? '04' : '04-1'),
        effective_date: tpl?.effective_date ?? '01-08-2026',
        form_subtitle: profile.form_subtitle ? (isStage1 ? profile.form_subtitle.stage1 : profile.form_subtitle.full) : '',
        report_title: titleInfo?.title ?? '', report_title_line2: titleInfo?.line2 ?? '', docbox_name: titleInfo?.docbox ?? '',
        boxes_custom: !!profile.audit_type_boxes,
        audit_type_boxes: profile.audit_type_boxes ? profile.audit_type_boxes(r.stage_type, x) : [
          { text: 'Stage 1 (Initial)', on: r.stage_type === 'stage1' },
          { text: 'Stage 2 (Initial)', on: r.stage_type === 'stage2' },
          { text: 'Surveillance', on: r.stage_type === 'surveillance' },
          { text: 'Recertification', on: r.stage_type === 'recert' },
        ],
      },
      n: nums,
      cover: {
        company: s(f.name), standard_name: stdName,
        standard_main: stdBase,
        // two-tone covers (ISO 45001): "ISO" + " 45001:2018"
        standard_first: stdBase.includes(' ') ? stdBase.slice(0, stdBase.indexOf(' ')) : '',
        standard_rest: stdBase.includes(' ') ? stdBase.slice(stdBase.indexOf(' ') + 1) : '',
        standard_sub: stdTitle,
        multi: multiStd,
        standards: allStd.map((x) => ({ main: s(x.name), sub: s(x.title) })),
        ims_columns: imsColumns,
        accreditation_ascb: /ascb/i.test(accreditation),
        accreditation_other: !!accreditation && !/ascb/i.test(accreditation),
        accreditation,
      },
      conclusion_options: CONCLUSION_OPTIONS.map((o) => ({ text: o.text, on: r.conclusion === o.key })),
      org: { ...f, multisite_yes: /^y/i.test(s(f.multisite)), multisite_no: /^n/i.test(s(f.multisite)) },
      change_detail: changeDetail,
      categories: profile.categories,
      labels: labelsFor(profile, stdBaseLabels),
      theme: profile.theme,
      system_short: profile.system_short,
      system_long: profile.system_long,
      disclaimer: DISCLAIMER,
      confidentiality: CONFIDENTIALITY,
      sections: {
        objective: sec('objective'), criteria: sec('criteria'), org_brief: sec('org_brief'), audit_brief: sec('audit_brief'),
        prev_nc: sec('prev_nc'), effectiveness: paragraphs(stripRich(effBody)), improvement_comments: sec('improvement_comments'),
      },
      sections_rich: {
        objective: parseRich(rawSec('objective')),
        criteria: parseRich(rawSec('criteria')),
        org_brief: parseRich(rawSec('org_brief')),
        audit_brief: parseRich(rawSec('audit_brief')),
        prev_nc: parseRich(rawSec('prev_nc')),
        effectiveness: parseRich(effBody),
        improvement_comments: parseRich(rawSec('improvement_comments')),
      },
      attendance: Array.from({ length: Math.max(8, attendance.length) }, (_, i) => ({
        name: attendance[i]?.name ?? '', designation: attendance[i]?.designation ?? '', signature_path: attendance[i]?.signature_path ?? '',
      })),
      images: imgBySection,
      clause_groups: groupOf('clause'),
      perf_groups: groupOf('performance'),
      risk_rows: groupOf('risk').flatMap((g) => g.rows),
      feedback_rows: groupOf('feedback').flatMap((g) => g.rows),
      statutory_rows: groupOf('statutory').flatMap((g) => g.rows),
      mark_use: MARK_USE_OPTIONS.map((o) => ({ text: o.text, on: x.mark_use === o.key })),
      mark_use_nc_no: s(x.mark_use_nc_no),
      nc_rows: ncRows,
      nc_counts: ncCounts,
      nc_counts_fixed: !!profile.finding_components,
      nc_totals: totals,
      declarations: DECLARATIONS.map((d) => ({ text: d.text, on: tick(x.declarations, d.key) })),
      attachments: profile.attachments.map((d) => ({ text: d.text, heading: !!d.heading, on: !d.heading && tick(x.attachments, d.key) })),
      recommendation_options: STAGE1_RECOMMENDATION_OPTIONS.map((o) => ({ text: o.text, on: r.recommendation === o.key })),
      next_audit_date: toDMY(r.next_audit_date),
      improvement: {
        modification_required: x.improvement === 'modification',
        no_action: x.improvement === 'none',
      },
      lead_auditor: s(f.lead_auditor),
      generated_on: toDMY(new Date()),
    };
  }
}
