// office-preview.ts — Excel / PowerPoint / Word ファイルのプレビュー用データを生成する共有ヘルパー。
//
// タスク用 (local-files-api.ts) とスペース用 (space-api.ts) の office-preview
// エンドポイントが、認証・パス封じ込めを済ませた「絶対パス」を渡して呼ぶ。
//
// - Excel (.xlsx/.xlsm): exceljs で各シートをセル文字列の二次元配列に変換 (Node 内で完結)。
// - PowerPoint (.pptx/.ppt) / Word (.docx): LibreOffice (soffice) で PDF 化 → PyMuPDF(fitz) で
//   各ページを PNG 化 → base64 data URL で返す。fitz は runtime に pre-baked 済み (PdfToImages と
//   同じ)。soffice だけ Dockerfile で同梱する。変換結果は version+mtime+size+DPI+maxPages を
//   キーに tmpdir へキャッシュ。pptx と docx は同じ変換経路で、上限ページ数と返却型だけが異なる。
//
// soffice 未導入環境 (ローカル dev など) では SofficeUnavailableError を投げる。呼出側は
// これを 503 + コードに変換し、UI は「変換エンジン未導入」を表示してダウンロードへ誘導する。

import { spawn } from 'child_process';
import { createHash } from 'crypto';
import { mkdirSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync, rmSync } from 'fs';
import { join, parse as parsePath } from 'path';
import { tmpdir } from 'os';
import type { Response } from 'express';
import ExcelJS from 'exceljs';
import { logger } from '../logger.js';

// ── 上限 (プレビューなので欲張らない) ────────────────────────────
const MAX_SHEETS = 30;
const MAX_ROWS = 500;
const MAX_COLS = 50;
const MAX_SLIDES = 100;
// Word 文書は 1 ページあたりの情報量が多く、base64 PNG を一括返却するとレスポンスが
// 過大になりやすい。スライドより控えめの上限にする。
const MAX_DOCUMENT_PAGES = 50;

// 呼び出し時に解決する (テストで差し替え可能にするため module 定数にしない)。
function sofficeBin(): string {
  return process.env.SOFFICE_BIN || 'soffice';
}
const CACHE_ROOT = join(tmpdir(), 'maestro-office-preview');
const CONVERT_TIMEOUT_MS = 120_000;
const RENDER_DPI = 110; // ページ画像の解像度 (見やすさ vs データ量のバランス)
// キャッシュ形式の版数。manifest 形式や DPI・変換方式を変えたら上げること。
// キャッシュキーに含めるので、上げれば旧キャッシュは自然にミスして再生成される。
const CACHE_VERSION = 'v2';

export interface SpreadsheetSheet {
  name: string;
  rows: string[][];
  /** シートの総行数 (上限で切る前の値) */
  rowCount: number;
  /** シートの総列数 (上限で切る前の値) */
  colCount: number;
  /** 行 or 列が上限で切られたか */
  truncated: boolean;
}

export interface SpreadsheetPreview {
  kind: 'spreadsheet';
  sheets: SpreadsheetSheet[];
  /** シート数 or いずれかのシートが切られたか */
  truncated: boolean;
}

export interface PresentationSlide {
  index: number;
  /** data:image/png;base64,... */
  dataUrl: string;
}

export interface PresentationPreview {
  kind: 'presentation';
  slides: PresentationSlide[];
  slideCount: number;
  /** スライド数が上限を超えて切られたか */
  truncated: boolean;
}

export interface DocumentPage {
  index: number;
  /** data:image/png;base64,... */
  dataUrl: string;
}

export interface DocumentPreview {
  kind: 'document';
  pages: DocumentPage[];
  /** 上限で切る前の総ページ数 */
  pageCount: number;
  /** ページ数が上限を超えて切られたか */
  truncated: boolean;
}

/** soffice が見つからない / 起動できないときに投げる。呼出側で 503 化する。 */
export class SofficeUnavailableError extends Error {
  constructor(message = 'LibreOffice (soffice) is not available') {
    super(message);
    this.name = 'SofficeUnavailableError';
  }
}

export function isSpreadsheetFile(name: string): boolean {
  return /\.(xlsx|xlsm)$/i.test(name);
}

export function isPresentationFile(name: string): boolean {
  return /\.(pptx|ppt)$/i.test(name);
}

// Word は soffice で PDF 化できる docx のみ。旧バイナリ .doc は再現精度が低く
// 壊れやすいため対象外 (Excel が旧 .xls を外しているのと同じ方針)。
export function isDocumentFile(name: string): boolean {
  return /\.docx$/i.test(name);
}

export function isOfficePreviewFile(name: string): boolean {
  return isSpreadsheetFile(name) || isPresentationFile(name) || isDocumentFile(name);
}

// ── HTTP レスポンスヘルパー (タスク用 / スペース用 エンドポイントで共用) ───────

/**
 * 拡張子に応じて Excel / PPTX のプレビュー JSON を res に書く。office 対象外の
 * 拡張子なら 415 を返す。例外 (soffice 未導入含む) は呼出側の catch で
 * handleOfficePreviewError に渡すこと。
 */
export async function sendOfficePreview(res: Response, absPath: string, name: string): Promise<void> {
  if (isSpreadsheetFile(name)) {
    res.json(await getSpreadsheetPreview(absPath));
    return;
  }
  if (isPresentationFile(name)) {
    res.json(await getPresentationPreview(absPath));
    return;
  }
  if (isDocumentFile(name)) {
    res.json(await getDocumentPreview(absPath));
    return;
  }
  res.status(415).json({ error: 'not an office-previewable file' });
}

/** office-preview エンドポイント共通の例外 → HTTP 変換。 */
export function handleOfficePreviewError(res: Response, err: unknown): void {
  if (err instanceof SofficeUnavailableError) {
    res.status(503).json({ error: 'converter_unavailable', message: err.message });
    return;
  }
  const e = err as NodeJS.ErrnoException;
  if (e?.code === 'ENOENT') {
    res.status(404).json({ error: 'not found' });
    return;
  }
  // パス封じ込めエラー (ensurePathWithin) のメッセージ規約に合わせる。
  if (typeof e?.message === 'string' && /escape/i.test(e.message)) {
    res.status(400).json({ error: 'Path escapes workspace' });
    return;
  }
  logger.error(`[office-preview] ${e?.message ?? err}`);
  res.status(500).json({ error: 'Failed to render office preview' });
}

// ── Excel ────────────────────────────────────────────────────

export async function getSpreadsheetPreview(absPath: string): Promise<SpreadsheetPreview> {
  const wb = new ExcelJS.Workbook();
  await wb.xlsx.readFile(absPath);

  let truncated = false;
  const allSheets = wb.worksheets;
  if (allSheets.length > MAX_SHEETS) truncated = true;

  const sheets: SpreadsheetSheet[] = [];
  for (const ws of allSheets.slice(0, MAX_SHEETS)) {
    const totalRows = ws.rowCount;
    const totalCols = ws.columnCount;
    const colLimit = Math.min(totalCols, MAX_COLS);
    const rowLimit = Math.min(totalRows, MAX_ROWS);
    const sheetTruncated = totalRows > MAX_ROWS || totalCols > MAX_COLS;
    if (sheetTruncated) truncated = true;

    const rows: string[][] = [];
    for (let r = 1; r <= rowLimit; r++) {
      const row = ws.getRow(r);
      const cells: string[] = [];
      for (let c = 1; c <= colLimit; c++) {
        cells.push(cellText(row.getCell(c)));
      }
      rows.push(cells);
    }
    sheets.push({ name: ws.name, rows, rowCount: totalRows, colCount: totalCols, truncated: sheetTruncated });
  }

  return { kind: 'spreadsheet', sheets, truncated };
}

/** exceljs のセルを表示用の文字列に変換する (数式は結果、リッチテキストは平文、日付は ISO 風)。 */
function cellText(cell: ExcelJS.Cell): string {
  const v = cell.value;
  if (v === null || v === undefined) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
  if (v instanceof Date) return v.toISOString().slice(0, 19).replace('T', ' ');
  if (typeof v === 'object') {
    const obj = v as unknown as Record<string, unknown>;
    // 数式: { formula, result }
    if ('result' in obj && obj.result != null) {
      const r = obj.result;
      if (r instanceof Date) return r.toISOString().slice(0, 19).replace('T', ' ');
      if (typeof r === 'object') return cell.text ?? '';
      return String(r);
    }
    // リッチテキスト: { richText: [{text}] }
    if (Array.isArray(obj.richText)) {
      return (obj.richText as { text?: string }[]).map((t) => t.text ?? '').join('');
    }
    // ハイパーリンク: { text, hyperlink }
    if ('text' in obj && typeof obj.text === 'string') return obj.text;
    // エラー: { error }
    if ('error' in obj) return String(obj.error);
  }
  // 最終手段: exceljs の整形済みテキスト
  return cell.text ?? '';
}

// ── PowerPoint / Word (soffice → PDF → ページ PNG) ─────────────
//
// pptx も docx も「office ファイル → PDF → ページ画像」で処理は共通。唯一の違いは
// 生成する最大ページ数 (maxPages) と、返却する型 (presentation / document)。

export async function getPresentationPreview(absPath: string): Promise<PresentationPreview> {
  const { pngFiles, totalPages } = await renderOfficePagesToPngs(absPath, MAX_SLIDES);
  const slides: PresentationSlide[] = pngFiles.map((file, i) => ({
    index: i + 1,
    dataUrl: `data:image/png;base64,${readFileSync(file).toString('base64')}`,
  }));

  return {
    kind: 'presentation',
    slides,
    slideCount: totalPages,
    truncated: totalPages > MAX_SLIDES,
  };
}

export async function getDocumentPreview(absPath: string): Promise<DocumentPreview> {
  const { pngFiles, totalPages } = await renderOfficePagesToPngs(absPath, MAX_DOCUMENT_PAGES);
  const pages: DocumentPage[] = pngFiles.map((file, i) => ({
    index: i + 1,
    dataUrl: `data:image/png;base64,${readFileSync(file).toString('base64')}`,
  }));

  return {
    kind: 'document',
    pages,
    pageCount: totalPages,
    truncated: totalPages > MAX_DOCUMENT_PAGES,
  };
}

const CACHE_MANIFEST = '.ok';

interface RenderResult {
  /** slide-N.png の絶対パス (番号順、maxPages で打ち切り済み)。 */
  pngFiles: string[];
  /** 上限で切る前の総ページ数 (soffice が生成した PDF のページ数)。 */
  totalPages: number;
}

/**
 * office ファイルをページ PNG に変換する (キャッシュ付き)。maxPages で生成枚数を制限する。
 * キャッシュキーには version・DPI・maxPages を含めるので、これらを変えると自然に再生成される。
 */
async function renderOfficePagesToPngs(absPath: string, maxPages: number): Promise<RenderResult> {
  const st = statSync(absPath);
  const key = createHash('sha1')
    .update(`${CACHE_VERSION}:${absPath}:${st.mtimeMs}:${st.size}:dpi${RENDER_DPI}:max${maxPages}`)
    .digest('hex');
  const cacheDir = join(CACHE_ROOT, key);

  const cached = readCachedRender(cacheDir);
  if (cached) return cached;
  return convertOfficeToPngs(absPath, cacheDir, maxPages);
}

/**
 * キャッシュ済みなら { pngFiles, totalPages } を返す。manifest が無い・空・旧形式 (空文字)・
 * 壊れた JSON・version 不一致・PNG が 1 枚も無い、のいずれかなら null (= 再生成)。
 */
function readCachedRender(cacheDir: string): RenderResult | null {
  const manifestPath = join(cacheDir, CACHE_MANIFEST);
  if (!existsSync(manifestPath)) return null;
  let totalPages: number;
  try {
    const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: string; totalPages?: number };
    if (parsed.version !== CACHE_VERSION || typeof parsed.totalPages !== 'number' || !Number.isFinite(parsed.totalPages)) {
      return null;
    }
    totalPages = parsed.totalPages;
  } catch {
    return null; // 空文字 (旧形式) や壊れた内容 → 再生成
  }
  const pngFiles = listSlidePngs(cacheDir);
  if (pngFiles.length === 0) return null;
  return { pngFiles, totalPages };
}

function listSlidePngs(dir: string): string[] {
  if (!existsSync(dir)) return [];
  return readdirSync(dir)
    .filter((f) => /^slide-\d+\.png$/.test(f))
    .sort((a, b) => slideNum(a) - slideNum(b))
    .map((f) => join(dir, f));
}

function slideNum(file: string): number {
  const m = file.match(/slide-(\d+)\.png$/);
  return m ? parseInt(m[1], 10) : 0;
}

async function convertOfficeToPngs(absPath: string, cacheDir: string, maxPages: number): Promise<RenderResult> {
  // キャッシュディレクトリを作り直す (古い png が残らないように)。
  rmSync(cacheDir, { recursive: true, force: true });
  mkdirSync(cacheDir, { recursive: true });

  // soffice はユーザープロファイルを必要とする。並行変換でプロファイルロックが
  // 衝突しないよう、変換ごとに専用プロファイルを使う。
  const profileDir = join(cacheDir, '.soffice-profile');
  mkdirSync(profileDir, { recursive: true });

  // 1) office → pdf (LibreOffice)
  await runSoffice(absPath, cacheDir, profileDir);
  const pdfPath = join(cacheDir, `${parsePath(absPath).name}.pdf`);
  if (!existsSync(pdfPath)) {
    throw new Error('LibreOffice conversion did not produce a PDF');
  }

  // 2) pdf → png/ページ (PyMuPDF, PdfToImages と同方式)。総ページ数を受け取る。
  const totalPages = await runPdfToPngs(pdfPath, cacheDir, maxPages);

  // 後始末: pdf とプロファイルは不要 (png だけ残す)
  rmSync(pdfPath, { force: true });
  rmSync(profileDir, { recursive: true, force: true });

  const pngs = listSlidePngs(cacheDir);
  if (pngs.length === 0) {
    throw new Error('No page images were generated');
  }
  // manifest は JSON。version + 総ページ数を保存し、次回以降 truncated 判定に使う。
  writeFileSync(
    join(cacheDir, CACHE_MANIFEST),
    JSON.stringify({ version: CACHE_VERSION, totalPages, generated: pngs.length }),
  );
  return { pngFiles: pngs, totalPages };
}

function runSoffice(absPath: string, outDir: string, profileDir: string): Promise<void> {
  return new Promise((resolve, reject) => {
    let settled = false;
    const done = (err?: Error) => {
      if (settled) return;
      settled = true;
      err ? reject(err) : resolve();
    };

    const bin = sofficeBin();
    const proc = spawn(
      bin,
      [
        '--headless',
        '--norestore',
        '--nolockcheck',
        `-env:UserInstallation=file://${profileDir}`,
        '--convert-to',
        'pdf',
        '--outdir',
        outDir,
        absPath,
      ],
      { stdio: ['ignore', 'pipe', 'pipe'] },
    );

    let stderr = '';
    proc.stdout.on('data', () => {});
    proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });

    proc.on('error', (err) => {
      clearTimeout(timer);
      // soffice が PATH に無い → ENOENT。呼出側で 503 化する。
      if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
        done(new SofficeUnavailableError(`'${bin}' not found on PATH`));
        return;
      }
      done(new SofficeUnavailableError(err.message));
    });

    const timer = setTimeout(() => {
      proc.kill('SIGKILL');
      done(new Error(`LibreOffice conversion timed out after ${CONVERT_TIMEOUT_MS / 1000}s`));
    }, CONVERT_TIMEOUT_MS);

    proc.on('close', (code) => {
      clearTimeout(timer);
      if (code !== 0) {
        done(new Error(`LibreOffice exited ${code}: ${stderr.trim().slice(0, 500)}`));
        return;
      }
      done();
    });
  });
}

/** PDF をページ PNG に変換し、PDF の総ページ数 (打ち切り前) を返す。 */
function runPdfToPngs(pdfPath: string, outDir: string, maxPages: number): Promise<number> {
  return new Promise((resolve, reject) => {
    let settled = false;
    const done = (err?: Error, totalPages?: number) => {
      if (settled) return;
      settled = true;
      err ? reject(err) : resolve(totalPages ?? 0);
    };

    const proc = spawn('python3', ['-'], {
      stdio: ['pipe', 'pipe', 'pipe'],
      env: {
        ...process.env,
        OFFICE_PDF_TO_PNG_ARGS: JSON.stringify({ pdf_path: pdfPath, output_dir: outDir, dpi: RENDER_DPI, max_pages: maxPages }),
      },
    });

    let stdout = '';
    let stderr = '';
    proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
    proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });

    proc.on('error', (err) => {
      clearTimeout(timer);
      done(new Error(`python3 process error: ${err.message}`));
    });

    const timer = setTimeout(() => {
      proc.kill('SIGKILL');
      done(new Error(`PDF render timed out after ${CONVERT_TIMEOUT_MS / 1000}s`));
    }, CONVERT_TIMEOUT_MS);

    proc.on('close', (code) => {
      clearTimeout(timer);
      if (code !== 0) {
        done(new Error(`PDF render failed (exit ${code}): ${(stderr || stdout).trim().slice(0, 500)}`));
        return;
      }
      try {
        const json = JSON.parse(stdout.trim()) as { error?: string; total_pages?: number; generated?: number };
        if (json.error) { done(new Error(`PDF render error: ${json.error}`)); return; }
        // total_pages は打ち切り前の総ページ数。欠けていれば生成枚数で代替する。
        const total = typeof json.total_pages === 'number' && Number.isFinite(json.total_pages)
          ? json.total_pages
          : (typeof json.generated === 'number' ? json.generated : 0);
        done(undefined, total);
      } catch {
        done(new Error(`PDF render: failed to parse output: ${stdout.slice(0, 200)}`));
        return;
      }
    });

    proc.stdin.write(PDF_TO_PNG_PYTHON);
    proc.stdin.end();
  });
}

// PdfToImages の Python と同じ PyMuPDF(fitz) で PDF を slide-N.png に変換する。
// 出力ファイル名は番号 1 始まり (slide-1.png ...)。
const PDF_TO_PNG_PYTHON = `
import sys, json, os

def main():
    try:
        import fitz
    except ImportError:
        print(json.dumps({'error': 'PyMuPDF (fitz) is not installed'}))
        sys.exit(1)

    raw = os.environ.get('OFFICE_PDF_TO_PNG_ARGS', '')
    if not raw:
        print(json.dumps({'error': 'OFFICE_PDF_TO_PNG_ARGS env var is not set'}))
        sys.exit(1)

    args = json.loads(raw)
    pdf_path   = args['pdf_path']
    output_dir = args['output_dir']
    dpi        = int(args.get('dpi', 110))
    max_pages  = int(args.get('max_pages', 100))

    os.makedirs(output_dir, exist_ok=True)
    try:
        doc = fitz.open(pdf_path)
    except Exception as e:
        print(json.dumps({'error': f'Failed to open PDF: {e}'}))
        sys.exit(1)

    scale = dpi / 72
    mat = fitz.Matrix(scale, scale)
    count = min(len(doc), max_pages)
    for idx in range(count):
        pix = doc[idx].get_pixmap(matrix=mat)
        pix.save(os.path.join(output_dir, f'slide-{idx + 1}.png'))

    print(json.dumps({'total_pages': len(doc), 'generated': count}))

main()
`;
