import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AppLoggerService } from 'src/common/services/logger.service';
import { AwsS3Service } from 'src/common/services/awsS3.service';
import { BrowserManagerService } from 'src/common/services/browser-manager.service';
import { generatePDF } from 'src/common/utils/common.util';
import {
  SESSION_GUIDELINES,
  SESSION_INVITE_GUIDELINES,
} from 'src/common/templates/session-guidelines.template';
import {
  SessionGuidelinesMergeData,
  SessionGuidelinesPdfContract,
} from './session-guidelines-pdf.types';

/**
 * Generates the session-guidelines PDFs and caches each PDF's S3 URL on its owning row:
 * - Program-level (Welcome) → program.guidelines_pdf_url — the generic checklist.
 * - Session-level (Invite)  → program_session.guidelines_pdf_url — adds per-session details
 *   (remaining program dates + recommended login time).
 *
 * Generation mirrors the Terms & Conditions PDF: the shared generatePDF util (puppeteer via
 * BrowserManagerService) renders the HTML, and the program logo — hosted in a private S3
 * bucket Puppeteer can't fetch by URL — is embedded as a base64 data URI.
 *
 * Lazy + cached: the first send renders + uploads + stores the bare S3 URL; later sends reuse
 * the cached row. Since the bucket is private, every resolve — cache hit or fresh generation —
 * returns a freshly-signed (7-day) URL via AwsS3Service.generateLongLivedSignedUrl so external
 * fetchers (WATI/Meta downloading the document to attach to the WhatsApp message) can reach it.
 * Best-effort — on any failure it logs and returns '' so a send is never blocked by PDF gen.
 */
@Injectable()
export class SessionGuidelinesPdfService implements SessionGuidelinesPdfContract {
  constructor(
    @InjectDataSource()
    private readonly dataSource: DataSource,
    private readonly awsS3Service: AwsS3Service,
    private readonly browserManager: BrowserManagerService,
    private readonly logger: AppLoggerService,
  ) {}

  /**
   * Program-level guidelines PDF URL. Returns the cached value when present; otherwise renders
   * the generic checklist with the program's logo, uploads, stores on
   * program.guidelines_pdf_url, and returns it.
   */
  async resolveProgramPdfUrl(programId: number): Promise<string> {
    try {
      const row = await this.dataSource
        .createQueryBuilder()
        .select('p.guidelines_pdf_url', 'guidelinesPdfUrl')
        .addSelect('p.banner_image_url', 'bannerImageUrl')
        .addSelect('p.name', 'name')
        .from('program_v1', 'p')
        .where('p.id = :programId', { programId })
        .getRawOne();
      if (!row) {
        return '';
      }
      if (row.guidelinesPdfUrl) {
        return this.awsS3Service.generateLongLivedSignedUrl(row.guidelinesPdfUrl);
      }
      const bannerImageUrl = await this.resolveImageDataUri(
        (row.bannerImageUrl as string | null) ?? null,
      );
      const key = `program/${programId}/${this.buildGuidelinesFileName(
        (row.name as string | null) ?? null,
      )}`;
      const { buffer } = await generatePDF(
        { bannerImageUrl },
        SESSION_GUIDELINES,
        key,
        this.browserManager,
        { singlePage: true },
      );
      const url = await this.awsS3Service.uploadToS3(key, Buffer.from(buffer), 'application/pdf');
      if (url) {
        await this.storeUrl('program_v1', programId, url);
      }
      // The stored URL is cached for reuse across sends, but WATI/Meta fetch the document
      // from a private bucket — hand back a signed URL, not the bare S3 URL, so the fetch succeeds.
      return url ? this.awsS3Service.generateLongLivedSignedUrl(url) : url;
    } catch (error) {
      this.logFailure('program_v1', programId, error);
      return '';
    }
  }

  /**
   * Session-level guidelines PDF URL. Returns the cached value when present; otherwise renders
   * the Invite checklist with the program's logo + per-session merge values, uploads, stores on
   * program_session.guidelines_pdf_url, and returns it.
   *
   * `computeMergeData` is a thunk so the (query-backed) session merge values are resolved ONLY
   * on a cache miss — repeat sends that hit the cache skip that work entirely.
   */
  async resolveSessionPdfUrl(
    sessionId: number,
    computeMergeData: () => Promise<SessionGuidelinesMergeData>,
  ): Promise<string> {
    try {
      const row = await this.dataSource
        .createQueryBuilder()
        .select('s.guidelines_pdf_url', 'guidelinesPdfUrl')
        .addSelect('s.program_id', 'programId')
        .from('program_session', 's')
        .where('s.id = :sessionId', { sessionId })
        .getRawOne();
      if (!row) {
        return '';
      }
      if (row.guidelinesPdfUrl) {
        return this.awsS3Service.generateLongLivedSignedUrl(row.guidelinesPdfUrl);
      }
      const program = await this.resolveProgramForPdf(row.programId);
      const bannerImageUrl = await this.resolveImageDataUri(program.bannerImageUrl);
      const merge = await computeMergeData();
      const key = `session/${sessionId}/${this.buildGuidelinesFileName(program.name)}`;
      const { buffer } = await generatePDF(
        { bannerImageUrl, ...merge },
        SESSION_INVITE_GUIDELINES,
        key,
        this.browserManager,
        { singlePage: true },
      );
      const url = await this.awsS3Service.uploadToS3(key, Buffer.from(buffer), 'application/pdf');
      if (url) {
        await this.storeUrl('program_session', sessionId, url);
      }
      // The stored URL is cached for reuse across sends, but WATI/Meta fetch the document
      // from a private bucket — hand back a signed URL, not the bare S3 URL, so the fetch succeeds.
      return url ? this.awsS3Service.generateLongLivedSignedUrl(url) : url;
    } catch (error) {
      this.logFailure('program_session', sessionId, error);
      return '';
    }
  }

  private async resolveProgramForPdf(
    programId: number | null,
  ): Promise<{ name: string | null; bannerImageUrl: string | null }> {
    if (!programId) {
      return { name: null, bannerImageUrl: null };
    }
    const row = await this.dataSource
      .createQueryBuilder()
      .select('p.name', 'name')
      .addSelect('p.banner_image_url', 'bannerImageUrl')
      .from('program_v1', 'p')
      .where('p.id = :programId', { programId })
      .getRawOne();
    return {
      name: (row?.name as string | null) ?? null,
      bannerImageUrl: (row?.bannerImageUrl as string | null) ?? null,
    };
  }

  /**
   * Download filename for the guidelines PDF, derived from the program name so recipients see a
   * meaningful document name. Sanitised to a conservative, universally-safe set: only letters and
   * digits are kept; every run of anything else (spaces, punctuation, "/", "&", "(", ")", etc.) is
   * collapsed to a single underscore, and leading/trailing underscores are trimmed. This keeps the
   * name safe as an S3 key, a URL segment (no percent-encoding needed) and a WhatsApp/WATI document
   * filename. E.g. "This AND That (TAT) Online Live - Aug 2026" → "Guidelines-This_AND_That_TAT_Online_Live_Aug_2026.pdf".
   * Falls back to "Guidelines.pdf" when the program has no name.
   */
  private buildGuidelinesFileName(programName: string | null): string {
    const safeName = (programName ?? '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
    return safeName ? `Guidelines-${safeName}.pdf` : 'Guidelines.pdf';
  }

  /**
   * Embed an S3-hosted image as a base64 data URI (same as the T&C PDF): the media bucket is
   * private, so Puppeteer can't load it by URL. Returns null when there's no image or it can't
   * be fetched — the template then falls back to its built-in default.
   */
  private async resolveImageDataUri(imageUrl: string | null): Promise<string | null> {
    if (!imageUrl) {
      return null;
    }
    try {
      const key = this.awsS3Service.extractS3KeyFromUrl(imageUrl) || '';
      const buffer = key ? await this.awsS3Service.getS3ObjectAsBuffer(key) : null;
      if (!buffer) {
        return null;
      }
      const ext = key.split('.').pop()?.toLowerCase();
      const mime =
        ext === 'png'
          ? 'image/png'
          : ext === 'svg'
            ? 'image/svg+xml'
            : ext === 'webp'
              ? 'image/webp'
              : 'image/jpeg';
      return `data:${mime};base64,${buffer.toString('base64')}`;
    } catch (error) {
      this.logger.warn(`Could not embed image in guidelines PDF: ${(error as Error)?.message}`);
      return null;
    }
  }

  /**
   * Persist the generated URL on the owning row so later sends reuse it. `update(<tableName>)`
   * resolves to the entity metadata, so `.set()` must use the entity PROPERTY name
   * (guidelinesPdfUrl) — which both Program and ProgramSession share — not the DB column name.
   */
  private async storeUrl(
    table: 'program_v1' | 'program_session',
    id: number,
    url: string,
  ): Promise<void> {
    await this.dataSource
      .createQueryBuilder()
      .update(table)
      .set({ guidelinesPdfUrl: url })
      .where('id = :id', { id })
      .execute();
  }

  private logFailure(table: string, id: number, error: unknown): void {
    this.logger.error(
      `Failed to resolve guidelines PDF for ${table} ${id}: ${(error as Error)?.message}`,
      (error as Error)?.stack,
    );
  }
}
