import { Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';

export interface ExcelSheetInfo {
  name: string;
  rowCount: number;
  columns: string[];
}

export interface ExcelSheetRows {
  name: string;
  rows: string[][];
}

/**
 * Structured Excel reader used by the AI chat. ExcelJS handles standard
 * files; a direct OOXML fallback handles WPS-style files that prefix
 * every element with a namespace. The agent reads sheets on demand
 * (`list_excel_sheets` / `read_excel_rows`) instead of receiving one
 * fixed text dump.
 */
@Injectable()
export class AiExcelReaderService {
  async loadSheets(buffer: Buffer): Promise<ExcelSheetRows[]> {
    try {
      return await this.loadWithExcelJs(buffer);
    } catch {
      return this.loadWithFallback(buffer);
    }
  }

  async extractText(buffer: Buffer): Promise<string> {
    const sheets = await this.loadSheets(buffer);
    return sheets
      .map((sheet) => `# ${sheet.name}\n${sheet.rows.map((row) => row.join('\t')).join('\n')}`)
      .join('\n');
  }

  /** Sheet list + row counts + a short sample, small enough for prompts. */
  async overview(
    buffer: Buffer,
    sampleRows = 12,
  ): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
    const sheets = await this.loadSheets(buffer);
    const info = sheets.map((sheet) => ({
      name: sheet.name,
      rowCount: sheet.rows.length,
      columns: sheet.rows[0] ?? [],
    }));
    const lines: string[] = [];
    for (const sheet of sheets) {
      lines.push(`# ${sheet.name}（共 ${sheet.rows.length} 行）`);
      for (const row of sheet.rows.slice(0, sampleRows)) {
        lines.push(row.join('\t'));
      }
      if (sheet.rows.length > sampleRows) {
        lines.push(`…（其余 ${sheet.rows.length - sampleRows} 行未显示）`);
      }
    }
    return { sheets: info, text: lines.join('\n') };
  }

  async readRows(
    buffer: Buffer,
    sheetName: string | undefined,
    startRow: number,
    rowCount: number,
    maxColumns: number,
  ): Promise<{
    sheet: string;
    rowCount: number;
    startRow: number;
    rows: string[][];
    truncated: boolean;
  }> {
    const sheets = await this.loadSheets(buffer);
    const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];
    if (!sheet) {
      return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false };
    }
    const from = Math.max(0, startRow - 1);
    const limit = Math.min(rowCount, 200);
    const slice = sheet.rows.slice(from, from + limit);
    const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50)));
    return {
      sheet: sheet.name,
      rowCount: sheet.rows.length,
      startRow: from + 1,
      rows,
      truncated: slice.length < limit,
    };
  }

  private async loadWithExcelJs(buffer: Buffer): Promise<ExcelSheetRows[]> {
    const workbook = new ExcelJS.Workbook();
    await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
    const sheets: ExcelSheetRows[] = [];
    workbook.eachSheet((sheet) => {
      const rows: string[][] = [];
      sheet.eachRow((row) => {
        const values = Array.isArray(row.values) ? row.values.slice(1) : [];
        rows.push(values.map((value) => this.stringifyCellValue(value)));
      });
      sheets.push({ name: sheet.name, rows });
    });
    return sheets;
  }

  private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
    const zip = await JSZip.loadAsync(buffer);
    const readEntry = async (name: string): Promise<string | null> => {
      const entry = zip.file(name);
      return entry ? entry.async('string') : null;
    };
    const workbookXml = await readEntry('xl/workbook.xml');
    if (!workbookXml) throw new Error('workbook.xml missing');
    const stripPrefixes = (value: string): string =>
      value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
    const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? '');
    const relTargets = new Map<string, string>();
    for (const match of relsXml.matchAll(
      /<Relationship[^>]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g,
    )) {
      const target = match[2].replace(/^\/+/, '');
      relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`);
    }

    const sharedStrings = await this.parseSharedStringsFallback(readEntry);
    const sheets: ExcelSheetRows[] = [];
    const cleanWorkbook = stripPrefixes(workbookXml);
    for (const match of cleanWorkbook.matchAll(/<sheet\b[^>]*\/?>/g)) {
      const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');
      const name = tag.match(/\bname="([^"]+)"/)?.[1];
      const rid = tag.match(/\br:id="([^"]+)"/)?.[1];
      if (!name || !rid) continue;
      const target = relTargets.get(rid);
      const sheetXml = target ? await readEntry(target) : null;
      if (!sheetXml) continue;
      sheets.push({
        name: this.unescapeXml(name),
        rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings),
      });
    }
    return sheets;
  }

  private async parseSharedStringsFallback(
    readEntry: (name: string) => Promise<string | null>,
  ): Promise<string[]> {
    const xml = await readEntry('xl/sharedStrings.xml');
    if (!xml) return [];
    const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
    const strings: string[] = [];
    for (const match of clean.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
      const texts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
        this.unescapeXml(part[1]),
      );
      strings.push(texts.join(''));
    }
    return strings;
  }

  private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] {
    const rows: string[][] = [];
    const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
    for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
      const cells = new Map<number, string>();
      let maxColumn = -1;
      for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {
        const attrs = cellMatch[1];
        const refMatch = attrs.match(/\br="([A-Z]+)\d+"/);
        const column = refMatch ? this.columnIndex(refMatch[1]) : -1;
        const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n';
        const body = cellMatch[2] ?? '';
        let value = '';
        if (type === 's') {
          const index = Number(body.match(/<v>([^<]*)<\/v>/)?.[1] ?? '');
          value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
        } else if (type === 'inlineStr') {
          const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
            this.unescapeXml(part[1]),
          );
          value = texts.join('');
        } else {
          value = this.unescapeXml(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '');
          if (type === 'b') value = value === '1' ? 'true' : 'false';
        }
        if (column >= 0) {
          cells.set(column, value);
          maxColumn = Math.max(maxColumn, column);
        }
      }
      if (maxColumn < 0) continue;
      const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? '');
      if (values.every((value) => value === '')) continue;
      rows.push(values);
    }
    return rows;
  }

  private columnIndex(letters: string): number {
    let index = 0;
    for (const char of letters.toUpperCase()) {
      index = index * 26 + (char.charCodeAt(0) - 64);
    }
    return index - 1;
  }

  private unescapeXml(value: string): string {
    return value
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&apos;/g, "'")
      .replace(/&amp;/g, '&')
      .replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
        String.fromCodePoint(Number.parseInt(hex, 16)),
      )
      .replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec)));
  }

  private stringifyCellValue(value: unknown): string {
    if (value === null || value === undefined) return '';
    if (value instanceof Date) return value.toISOString();
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
      return String(value);
    }
    try {
      return JSON.stringify(value) || '';
    } catch {
      return '';
    }
  }
}
