import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview, formatFindingsStats, formatFindingsStatsLine } from '../comments.js';
import { FINDINGS_PATH } from '../config.js';

describe('saveFindings', () => {
  const tempDirs = [];
  const makeTempDir = prefix => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
    tempDirs.push(dir);
    return dir;
  };

  it('writes findings to workspace and mirror dirs when provided', () => {
    const workspace = makeTempDir('findings-ws-');
    const mirrorDir = makeTempDir('findings-mirror-');
    const findings = [{ level: 'warning', role: 'Leo', location: 'file.js:1', suggestion: 'test' }];

    saveFindings(workspace, findings, mirrorDir);

    const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
    const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8');
    assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
    assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n');
  });

  it('writes only to workspace when mirrorDir is omitted', () => {
    const workspace = makeTempDir('findings-ws-');
    const findings = [{ level: 'info', role: 'Maya', location: 'file.js:2', suggestion: 'note' }];

    saveFindings(workspace, findings);

    const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
    assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
  });

  it('does not duplicate writes when mirrorDir matches workspace', () => {
    const workspace = makeTempDir('findings-same-');
    const findings = [];
    const writeCalls = [];
    const originalWriteFileSync = fs.writeFileSync;

    fs.writeFileSync = (...args) => {
      writeCalls.push(args[0]);
      return originalWriteFileSync(...args);
    };

    try {
      saveFindings(workspace, findings, workspace);
    } finally {
      fs.writeFileSync = originalWriteFileSync;
    }

    assert.equal(writeCalls.length, 1);
    assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
  });

  it('writes an empty JSON array when findings is empty', () => {
    const workspace = makeTempDir('findings-empty-');

    saveFindings(workspace, []);

    const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
    assert.equal(workspaceText, '[]\n');
  });

  afterEach(() => {
    while (tempDirs.length > 0) {
      fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
    }
  });
});

describe('parseLocation', () => {
  it('parses file and single line', () => {
    assert.deepEqual(parseLocation('app/preflight.js:19'), { file: 'app/preflight.js', line: 19 });
  });

  it('uses the start line for a line range', () => {
    assert.deepEqual(parseLocation('app/preflight.js:70-82'), { file: 'app/preflight.js', line: 70 });
  });

  it('returns null when there is no line number', () => {
    assert.equal(parseLocation('app/preflight.test.js'), null);
  });

  it('returns null when the parsed line number is zero', () => {
    assert.equal(parseLocation('app/preflight.js:0'), null);
  });

  it('returns null when multiple files are listed', () => {
    assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
  });

  it('returns null for non-string input', () => {
    assert.equal(parseLocation(undefined), null);
  });
});

describe('formatFindingsStats', () => {
  const statsFindings = [
    { level: 'critical', is_new: false },
    { level: 'warning', is_new: true },
    { level: 'info' },
    { level: 'custom', is_new: true },
  ];

  it('formats old and new findings by severity with an unclassified column', () => {
    const stats = formatFindingsStats(statsFindings);

    assert.equal(stats, [
      '| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
      '| --- | --- | --- | --- | --- |',
      '| 新問題 | 0 筆 | 1 筆 | 1 筆 | 1 筆 |',
      '| 舊問題 | 1 筆 | 0 筆 | 0 筆 | 0 筆 |',
    ].join('\n'));
  });

  it('formats compact one-line stats for action logs', () => {
    assert.equal(
      formatFindingsStatsLine(statsFindings),
      '新: 嚴重0 / 警告1 / 建議1 / 無法標示1；舊: 嚴重1 / 警告0 / 建議0 / 無法標示0',
    );
  });
});

describe('postNewCriticalComments', () => {
  const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true };

  it('posts an inline review comment annotating file/line with level/role/suggestion', async () => {
    const inlineCalls = [];
    const issueCalls = [];
    await postNewCriticalComments([critical], {
      postInline: async (args) => { inlineCalls.push(args); },
      postIssue: async (body) => { issueCalls.push(body); },
    });
    assert.equal(inlineCalls.length, 1);
    assert.equal(issueCalls.length, 0);
    assert.equal(inlineCalls[0].path, 'app/preflight.js');
    assert.equal(inlineCalls[0].line, 19);
    assert.match(inlineCalls[0].body, /等級/);
    assert.match(inlineCalls[0].body, /審查員.*Rex/s);
    assert.match(inlineCalls[0].body, /建議.*修這個/s);
  });

  it('falls back to a normal comment when the location has no line number', async () => {
    const inlineCalls = [];
    const issueCalls = [];
    await postNewCriticalComments([{ ...critical, location: 'app/preflight.js' }], {
      postInline: async (args) => { inlineCalls.push(args); },
      postIssue: async (body) => { issueCalls.push(body); },
    });
    assert.equal(inlineCalls.length, 0);
    assert.equal(issueCalls.length, 1);
    assert.match(issueCalls[0], /嚴重問題/);
  });

  it('falls back to a normal comment when the inline post fails', async () => {
    const issueCalls = [];
    await postNewCriticalComments([critical], {
      postInline: async () => { throw new Error('line not in diff'); },
      postIssue: async (body) => { issueCalls.push(body); },
    });
    assert.equal(issueCalls.length, 1);
    assert.match(issueCalls[0], /嚴重問題/);
  });

  it('only posts for new critical findings', async () => {
    const inlineCalls = [];
    const issueCalls = [];
    await postNewCriticalComments([
      { ...critical, is_new: false },
      { level: 'warning', role: 'Leo', location: 'a.js:1', suggestion: 'x', is_new: true },
    ], {
      postInline: async (args) => { inlineCalls.push(args); },
      postIssue: async (body) => { issueCalls.push(body); },
    });
    assert.equal(inlineCalls.length, 0);
    assert.equal(issueCalls.length, 0);
  });

  it('posts nothing when given an empty findings array', async () => {
    const inlineCalls = [];
    const issueCalls = [];
    await postNewCriticalComments([], {
      postInline: async (args) => { inlineCalls.push(args); },
      postIssue: async (body) => { issueCalls.push(body); },
    });
    assert.equal(inlineCalls.length, 0);
    assert.equal(issueCalls.length, 0);
  });

  it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
    const criticalCommentPattern = /嚴重問題/;
    const inlineCalls = [];
    const issueCalls = [];
    const findings = [
      { ...critical, location: 'app/a.js:10', suggestion: 'A' }, // 有行號、inline 成功
      { ...critical, location: 'app/b.js', suggestion: 'B' },    // 無行號 → 降級為一般 comment
      { ...critical, location: 'app/c.js:20', suggestion: 'C' }, // inline 拋錯 → 降級為一般 comment
    ];
    await postNewCriticalComments(findings, {
      postInline: async (args) => {
        if (args.path === 'app/c.js') throw new Error('line not in diff');
        inlineCalls.push(args);
      },
      postIssue: async (body) => { issueCalls.push(body); },
    });
    assert.equal(inlineCalls.length, 1);
    assert.equal(inlineCalls[0].path, 'app/a.js');
    assert.equal(inlineCalls[0].line, 10);
    assert.equal(issueCalls.length, 2);
    assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
  });
});

describe('postFindingsReview', () => {
  const REVIEW_SEVERITY_LABELS = ['🔴 嚴重', '🟡 警告', '🔵 建議'];
  const REVIEW_SEVERITY_PATTERN = new RegExp(`\\*\\*嚴重等級\\*\\*：(${REVIEW_SEVERITY_LABELS.join('|')})(?:\\n|$)`);

  /**
   * 從 review comment body 擷取嚴重等級標籤。
   * @param {object | null | undefined} comment - 預期包含 body 欄位的 review comment。
   * @returns {string | undefined} 嚴重等級標籤；格式不符時回傳 undefined。
   */
  function reviewSeverityLabel(comment) {
    return comment?.body?.match(REVIEW_SEVERITY_PATTERN)?.[1];
  }

  it('handles missing review severity bodies gracefully', () => {
    assert.equal(reviewSeverityLabel(null), undefined);
    assert.equal(reviewSeverityLabel(undefined), undefined);
    assert.equal(reviewSeverityLabel({}), undefined);
    assert.equal(reviewSeverityLabel({ body: null }), undefined);
    assert.equal(reviewSeverityLabel({ body: undefined }), undefined);
  });

  it('extracts review severity labels only when the format is valid', () => {
    assert.equal(
      reviewSeverityLabel({ body: '**嚴重等級**：🔴 嚴重\n**審查員**：Rex' }),
      '🔴 嚴重',
    );
    assert.equal(reviewSeverityLabel({ body: '**審查員**：Rex' }), undefined);
    assert.equal(reviewSeverityLabel({ body: '**嚴重等級**：' }), undefined);
    assert.equal(reviewSeverityLabel({ body: '**嚴重等級**：高風險' }), undefined);
  });

  it('posts inline comments only for new findings, not old ones', async () => {
    const reviewCalls = [];
    const findings = [
      { level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'I', is_new: true },
      { level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'C', is_new: false },
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
    ];

    await postFindingsReview(findings, {
      summaryFindings: findings,
      commentFindings: findings,
      postReview: async (args) => { reviewCalls.push(args); },
    });

    assert.equal(reviewCalls.length, 1);
    assert.match(reviewCalls[0].body, /\| 類型 \| 🔴 嚴重 \| 🟡 警告 \| 🔵 建議 \|/);
    assert.match(reviewCalls[0].body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/);
    assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/);
    // 舊問題 app/a.js（is_new:false）不應被行內標註，僅新問題依嚴重等級排序後標註
    assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
    assert.deepEqual(
      reviewCalls[0].comments.map(c => c.path),
      ['app/b.js', 'app/c.js'],
    );
    assert.deepEqual(
      reviewCalls[0].comments.map(reviewSeverityLabel),
      ['🟡 警告', '🔵 建議'],
    );
    assert.deepEqual(
      reviewCalls[0].comments.map(c => c.new_position),
      [20, 30],
    );
    assert.match(reviewCalls[0].comments[0].body, /嚴重等級/);
    assert.match(reviewCalls[0].comments[0].body, /審查員.*Leo/s);
    assert.match(reviewCalls[0].comments[0].body, /建議.*W/s);
  });

  it('appends the usage section to the review body when provided', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
    ], {
      postReview: async (args) => { reviewCalls.push(args); },
      usageSection: '## 🤖 AI 助理使用量\n\n本次：120 token',
    });

    assert.match(reviewCalls[0].body, /## AI Code Review 統計/);
    assert.match(reviewCalls[0].body, /## 🤖 AI 助理使用量\n\n本次：120 token$/);
  });

  it('omits the usage section when not provided', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
    ], {
      postReview: async (args) => { reviewCalls.push(args); },
    });

    assert.doesNotMatch(reviewCalls[0].body, /AI 助理使用量/);
    // usageSection 省略時，body 不應殘留多餘的尾端空白／換行
    assert.equal(reviewCalls[0].body, reviewCalls[0].body.trimEnd());
  });

  it('appends usageSection verbatim after the stats block without altering structure', async () => {
    const reviewCalls = [];
    const usageSection = '## 🤖 AI 助理使用量\n\n| x | y |\n| - | - |\n| 1 | 2 |';
    await postFindingsReview([
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
    ], { postReview: async (args) => { reviewCalls.push(args); }, usageSection });

    const body = reviewCalls[0].body;
    // 統計區塊在前、usageSection 原樣接在後（中間一個空行）；不交錯、不被竄改
    assert.ok(body.startsWith('## AI Code Review 統計'));
    assert.ok(body.endsWith(usageSection));
    assert.match(body, /## AI Code Review 統計[\s\S]*\n\n## 🤖 AI 助理使用量/);
  });

  it('counts both new and old findings in the summary but only inline-comments new ones', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old crit', is_new: false },
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warn', is_new: true },
      { level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info', is_new: true },
    ], { postReview: async (a) => { reviewCalls.push(a); } });

    const body = reviewCalls[0].body;
    assert.match(body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \| 0 筆 \|/);
    assert.match(body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \| 0 筆 \|/);
    // 舊問題 app/a.js 不產生行內 comment；只有新問題被標註
    assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
    assert.deepEqual(reviewCalls[0].comments.map(c => c.path), ['app/b.js', 'app/c.js']);
  });

  it('separates old and new findings in default review statistics', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old critical', is_new: false },
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warning', is_new: true },
      { level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info' },
    ], {
      postReview: async (args) => { reviewCalls.push(args); },
    });

    assert.equal(reviewCalls.length, 1);
    assert.match(reviewCalls[0].body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/);
    assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/);
    // 統計含新舊（舊問題仍計入本文），但行內 comment 只給新問題（舊 critical 不標註）
    assert.equal(reviewCalls[0].comments.length, 2);
    assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
  });

  it('only adds comments for findings with parseable file and line', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'critical', role: 'Rex', location: 'app/a.js', suggestion: 'missing line', is_new: true },
      { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'line', is_new: true },
    ], {
      postReview: async (args) => { reviewCalls.push(args); },
    });

    assert.equal(reviewCalls.length, 1);
    assert.match(reviewCalls[0].body, /\| 新問題 \| 1 筆 \| 1 筆 \| 0 筆 \|/);
    assert.equal(reviewCalls[0].comments.length, 1);
    assert.equal(reviewCalls[0].comments[0].path, 'app/b.js');
  });

  it('uses an explicit problem field when present', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'warning', role: 'Leo', location: 'app/a.js:5', problem: '命名不清楚', suggestion: '改成具體名稱' },
    ], {
      postReview: async (args) => { reviewCalls.push(args); },
    });

    assert.match(reviewCalls[0].comments[0].body, /問題.*命名不清楚/s);
    assert.match(reviewCalls[0].comments[0].body, /建議.*改成具體名稱/s);
  });

  it('uses reviewer reason fields as the problem text instead of the location', async () => {
    const reviewCalls = [];
    await postFindingsReview([
      { level: 'warning', role: 'Leo', location: 'app/a.js:5', description: '這裡缺少空值檢查', suggestion: '先判斷 null 再使用' },
    ], {
      postReview: async (args) => { reviewCalls.push(args); },
    });

    assert.match(reviewCalls[0].comments[0].body, /問題.*這裡缺少空值檢查/s);
    assert.doesNotMatch(reviewCalls[0].comments[0].body, /問題.*app\/a\.js:5/s);
  });

  it('falls back to summary review and per-comment posting when batch review fails', async () => {
    const reviewCalls = [];
    const inlineCalls = [];
    await postFindingsReview([
      { level: 'warning', role: 'Leo', location: 'app/a.js:5', suggestion: '修正 A', is_new: true },
      { level: 'info', role: 'Maya', location: 'app/b.js:9', suggestion: '修正 B', is_new: true },
    ], {
      postReview: async (args) => {
        reviewCalls.push(args);
        if (args.comments.length > 0) throw new Error('Request failed with status code 500');
      },
      postInline: async (args) => {
        inlineCalls.push(args);
        if (args.path === 'app/b.js') throw new Error('line not in diff');
      },
      postIssue: async () => {
        throw new Error('一般 comment 不應被呼叫');
      },
    });

    assert.equal(reviewCalls.length, 2);
    assert.equal(reviewCalls[0].comments.length, 2);
    assert.equal(reviewCalls[1].comments.length, 0);
    assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']);
  });
});

describe('formatFindingsStats markdown formatting', () => {
  // 代表性輸入：critical/warning/info 混合，含 is_new:false（舊問題）與未分類等級（custom）
  const mixedFindings = [
    { level: 'critical', is_new: true },
    { level: 'critical', is_new: true },
    { level: 'warning', is_new: true },
    { level: 'info' }, // 未設 is_new 視為新問題
    { level: 'custom', is_new: true }, // 無法標示（不在 LEVEL_ORDER）
    { level: 'critical', is_new: false }, // 舊問題
    { level: 'warning', is_new: false }, // 舊問題
    { level: 'mystery', is_new: false }, // 舊問題 + 無法標示
  ];

  it('emits the exact header, separator and one row per type for mixed findings', () => {
    const stats = formatFindingsStats(mixedFindings);

    assert.equal(stats, [
      '| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
      '| --- | --- | --- | --- | --- |',
      '| 新問題 | 2 筆 | 1 筆 | 1 筆 | 1 筆 |',
      '| 舊問題 | 1 筆 | 1 筆 | 0 筆 | 1 筆 |',
    ].join('\n'));
  });

  it('produces a structurally valid markdown table (4 lines, 6 pipes each, 5 columns)', () => {
    const lines = formatFindingsStats(mixedFindings).split('\n');

    // 表頭 + 分隔列 + 新問題列 + 舊問題列
    assert.equal(lines.length, 4);
    // 每列皆以 pipe 起訖
    for (const row of lines) {
      assert.ok(row.startsWith('| '), `row should start with a pipe: ${row}`);
      assert.ok(row.endsWith(' |'), `row should end with a pipe: ${row}`);
      // 5 欄 => 6 個 pipe 分隔符
      assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
    }
    // 分隔列每格皆為 ---
    assert.equal(lines[1], '| --- | --- | --- | --- | --- |');
    // 資料列以 類型 標籤起頭
    assert.match(lines[2], /^\| 新問題 \|/);
    assert.match(lines[3], /^\| 舊問題 \|/);
  });

  it('returns a stable, non-broken table for an empty findings array', () => {
    let stats;
    assert.doesNotThrow(() => { stats = formatFindingsStats([]); });

    assert.equal(stats, [
      '| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
      '| --- | --- | --- | --- | --- |',
      '| 新問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
      '| 舊問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
    ].join('\n'));
    // 即使無資料仍維持 4 列、每列 6 個 pipe 的結構
    const lines = stats.split('\n');
    assert.equal(lines.length, 4);
    for (const row of lines) {
      assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
    }
  });
});

describe('formatFindingsStatsLine markdown formatting', () => {
  const mixedFindings = [
    { level: 'critical', is_new: true },
    { level: 'critical', is_new: true },
    { level: 'warning', is_new: true },
    { level: 'info' },
    { level: 'custom', is_new: true },
    { level: 'critical', is_new: false },
    { level: 'warning', is_new: false },
    { level: 'mystery', is_new: false },
  ];

  it('produces the exact single-line summary for mixed findings', () => {
    assert.equal(
      formatFindingsStatsLine(mixedFindings),
      '新: 嚴重2 / 警告1 / 建議1 / 無法標示1；舊: 嚴重1 / 警告1 / 建議0 / 無法標示1',
    );
  });

  it('returns a stable single line with zero counts for an empty findings array', () => {
    let lineSummary;
    assert.doesNotThrow(() => { lineSummary = formatFindingsStatsLine([]); });

    assert.equal(
      lineSummary,
      '新: 嚴重0 / 警告0 / 建議0 / 無法標示0；舊: 嚴重0 / 警告0 / 建議0 / 無法標示0',
    );
    // 單行：不含換行
    assert.ok(!lineSummary.includes('\n'));
  });
});
