import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
  __setDeviceNotesStoreForTesting,
  createDeviceNotesStore,
  readDeviceNotes,
  saveDeviceNote,
} from "@/lib/settings/ds-device-notes";

/**
 * D-Sデバイス備考（`lib/settings/ds-device-notes.ts`）の期待値表テスト。
 *
 * ★fs はモックしません★（`json-store.test.ts` / `advice.test.ts` と同じ方針）。
 * 守りたい性質が「壊れたファイルの中身が本当に `.bak` に残るか」といった
 * ファイルシステムそのものの挙動だからです。各テストは `fs.mkdtemp` で作った
 * 独立した一時ディレクトリを使い、afterEach で必ず消します。
 *
 * ★`@/lib/logger` はモックします★。
 * `lib/logger/index.ts` は `@/lib/db` 経由で fixtures 全体を引き込むうえ、
 * ここで確かめたいのは「破損時に `settings.store_corrupt` を warn で1行出す」という
 * 配線だけで、ロガー本体の挙動は `lib/logger/__tests__/` の担当だからです。
 */
const logEventMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logger", () => ({ logEvent: logEventMock }));

/** 破損させたファイルの中身。`.bak` に原文が残ることの確認に使い回す */
const BROKEN_JSON = '{ "DS-001": "途中で切れた"';

let dir = "";
let filePath = "";

beforeEach(async () => {
  dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-"));
  filePath = path.join(dir, "ds-device-notes.json");
  // 実ファイル（data/settings/ds-device-notes.json）を汚さないよう、必ず一時パスへ差し替える
  __setDeviceNotesStoreForTesting(createDeviceNotesStore(filePath));
  logEventMock.mockClear();
});

afterEach(async () => {
  __setDeviceNotesStoreForTesting(null);
  await fs.rm(dir, { recursive: true, force: true });
});

async function readSavedJson(): Promise<Record<string, unknown>> {
  return JSON.parse(await fs.readFile(filePath, "utf-8")) as Record<
    string,
    unknown
  >;
}

async function findBackupPath(): Promise<string> {
  const names = await fs.readdir(dir);
  const backup = names.find((n) => n.endsWith(".bak"));
  expect(backup).toBeDefined();
  return path.join(dir, backup as string);
}

describe("readDeviceNotes()", () => {
  test("ファイルが無くても例外にならず、空で返る", async () => {
    const notes = await readDeviceNotes();

    // 「まだ誰も備考を入れていない」は正常な状態
    expect(Object.keys(notes)).toEqual([]);
    expect(notes).toEqual({});
    // 読んだだけでファイルを作らない
    expect(await fs.readdir(dir)).toEqual([]);
  });
});

describe("saveDeviceNote()", () => {
  test("保存した値がそのまま読み戻せる", async () => {
    const saved = await saveDeviceNote("DS-001", "制御盤の位置が変更されました");

    expect(saved).toBe("制御盤の位置が変更されました");

    const notes = await readDeviceNotes();
    expect(notes["DS-001"]).toBe("制御盤の位置が変更されました");
    expect(await readSavedJson()).toEqual({
      "DS-001": "制御盤の位置が変更されました",
    });
  });

  test("前後の空白は落として保存する（保存されるのはトリム後の値）", async () => {
    const saved = await saveDeviceNote("DS-001", "  電源系を要確認  ");

    expect(saved).toBe("電源系を要確認");
    expect((await readDeviceNotes())["DS-001"]).toBe("電源系を要確認");
  });

  test("同じデバイスを上書きすると、後から保存した値になる", async () => {
    await saveDeviceNote("DS-001", "1回目");
    const saved = await saveDeviceNote("DS-001", "2回目");

    expect(saved).toBe("2回目");
    expect(await readSavedJson()).toEqual({ "DS-001": "2回目" });
  });

  test("★空文字で保存するとキーごと消え、戻り値は null★", async () => {
    await saveDeviceNote("DS-001", "いったん入力した備考");

    const saved = await saveDeviceNote("DS-001", "");

    expect(saved).toBeNull();
    // 空文字を値として残さない（「未入力」と「空文字」を区別しない）
    expect(await readDeviceNotes()).toEqual({});
    expect(await readSavedJson()).toEqual({});
  });

  test("★前後の空白だけの文字列も削除扱いになる★", async () => {
    await saveDeviceNote("DS-001", "いったん入力した備考");

    const saved = await saveDeviceNote("DS-001", "   ");

    expect(saved).toBeNull();
    // 画面上は空欄に見えるのにキーが残る、という状態を作らない
    expect(await readDeviceNotes()).toEqual({});
    expect(await readSavedJson()).toEqual({});
  });

  test("未保存のデバイスを空文字で保存しても例外にならない", async () => {
    const saved = await saveDeviceNote("DS-999", "");

    expect(saved).toBeNull();
    expect(await readSavedJson()).toEqual({});
  });

  test("2件保存したあと1件だけ上書きしても、もう1件が消えない", async () => {
    await saveDeviceNote("DS-001", "1号機の備考");
    await saveDeviceNote("DS-002", "2号機の備考");

    // 全件読んで1件だけ差し替えているか（読まずに書くと DS-002 が消える）
    await saveDeviceNote("DS-001", "1号機の備考（更新）");

    expect(await readSavedJson()).toEqual({
      "DS-001": "1号機の備考（更新）",
      "DS-002": "2号機の備考",
    });
  });

  test("2件保存したあと1件だけ削除しても、もう1件が消えない", async () => {
    await saveDeviceNote("DS-001", "1号機の備考");
    await saveDeviceNote("DS-002", "2号機の備考");

    expect(await saveDeviceNote("DS-001", "")).toBeNull();

    const notes = await readDeviceNotes();
    expect(notes["DS-001"]).toBeUndefined();
    expect(notes["DS-002"]).toBe("2号機の備考");
    expect(await readSavedJson()).toEqual({ "DS-002": "2号機の備考" });
  });
});

describe("ファイルが壊れているとき", () => {
  test(".bak に退避し、空で動き、settings.store_corrupt を warn で出す", async () => {
    await fs.writeFile(filePath, BROKEN_JSON, "utf-8");

    // 壊れていても画面は落とさない。備考なしとして動く
    expect(await readDeviceNotes()).toEqual({});

    const backupPath = await findBackupPath();
    // /data は .gitignore なので、原文は .bak にしか残らない
    expect(await fs.readFile(backupPath, "utf-8")).toBe(BROKEN_JSON);

    expect(logEventMock).toHaveBeenCalledTimes(1);
    const [level, payload] = logEventMock.mock.calls[0] as [
      string,
      Record<string, unknown>,
    ];
    expect(level).toBe("warn");
    expect(payload.event).toBe("settings.store_corrupt");
    expect(payload.file_path).toBe(filePath);
    expect(payload.backup_path).toBe(backupPath);
    // 壊れ方の分類は入るが、壊れたファイルの中身そのものは載せない
    expect(typeof payload.reason).toBe("string");
    expect(payload.reason).toBeTruthy();
    expect(payload.reason).not.toContain("途中で切れた");
  });

  test("破損を読んだ直後に保存しても、.bak の中身は失われない", async () => {
    await fs.writeFile(filePath, BROKEN_JSON, "utf-8");
    await readDeviceNotes();
    const backupPath = await findBackupPath();

    // 退避直後の保存が .bak を巻き込んで潰さないこと（唯一の控えなので致命的）
    await saveDeviceNote("DS-001", "破損後の最初の備考");

    expect(await fs.readFile(backupPath, "utf-8")).toBe(BROKEN_JSON);
    expect(await readSavedJson()).toEqual({ "DS-001": "破損後の最初の備考" });
    // .bak は1つだけ（2回目の読み込みで増えない＝壊れたファイルは残っていない）
    expect(
      (await fs.readdir(dir)).filter((n) => n.endsWith(".bak"))
    ).toHaveLength(1);
  });
});

describe("プロトタイプ汚染", () => {
  test("ファイルに __proto__ というキーがあっても Object.prototype を汚さない", async () => {
    await fs.writeFile(
      filePath,
      '{ "__proto__": { "polluted": "yes" }, "DS-001": "正常" }',
      "utf-8"
    );

    const notes = await readDeviceNotes();

    expect((({}) as Record<string, unknown>).polluted).toBeUndefined();
    expect(
      (Object.prototype as unknown as Record<string, unknown>).polluted
    ).toBeUndefined();
    // 汚染しないだけで、正常なキーは失わない
    expect(notes["DS-001"]).toBe("正常");
  });

  test("__proto__ をシリアルにして保存しても Object.prototype を汚さない", async () => {
    const saved = await saveDeviceNote("__proto__", "汚染を狙う値");

    expect(saved).toBe("汚染を狙う値");
    // Object.prototype 自体が置き換わっていないこと
    expect(Object.getPrototypeOf({})).toBe(Object.prototype);
    expect(
      (Object.prototype as unknown as Record<string, unknown>).polluted
    ).toBeUndefined();

    // ★ここは toEqual で比較できません★。オブジェクトリテラルの `__proto__:` は
    // 自前プロパティではなくプロトタイプ指定として解釈されるため、
    // 期待値側が `{}` になってしまいます。キーと値を直接確かめます。
    const raw = await readSavedJson();
    expect(Object.keys(raw)).toEqual(["__proto__"]);
    expect(raw["__proto__"]).toBe("汚染を狙う値");

    // 読み戻しても普通のキーのまま（プロトタイプへ流れ込まない）
    const notes = await readDeviceNotes();
    expect(Object.keys(notes)).toEqual(["__proto__"]);
    expect(notes["__proto__"]).toBe("汚染を狙う値");
  });

  test("constructor をシリアルにしても壊れない", async () => {
    await saveDeviceNote("constructor", "紛らわしいシリアル");
    await saveDeviceNote("DS-001", "普通のシリアル");

    const notes = await readDeviceNotes();
    expect(notes["constructor"]).toBe("紛らわしいシリアル");
    expect(notes["DS-001"]).toBe("普通のシリアル");
  });
});
