import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';

import { FileSessionTreeProvider } from '../src/modules/netaclaw/session-tree/file_provider.js';
import { SessionTreeProviderError } from '../src/modules/netaclaw/session-tree/provider.js';
import { runSessionTreeProviderContract } from './session_tree_provider_contract.test.js';

describe('FileSessionTreeProvider', () => {
  let tempRoot: string;

  beforeEach(async () => {
    tempRoot = await mkdtemp(path.join(tmpdir(), 'neta-session-tree-'));
  });

  afterEach(async () => {
    if (tempRoot) {
      await rm(tempRoot, { recursive: true, force: true });
    }
  });

  runSessionTreeProviderContract('file', () => new FileSessionTreeProvider({
    rootDir: tempRoot,
    now: () => '2026-04-19T00:00:00.000Z',
    cwd: () => 'C:/workspace/neta',
  }));

  it('writes one Pi-first JSONL file per session with header on the first line and entries after it', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const session = await provider.createSession({
      sessionId: 'session_file_format',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      message: {
        role: 'user',
        content: 'root',
      },
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_leaf',
      parentId: 'entry_root',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'custom_message',
      customType: 'note',
      content: {
        text: 'leaf',
      },
      display: true,
    });

    const files = await readdir(tempRoot);
    expect(files).toHaveLength(1);
    expect(files[0]).toMatch(/\.jsonl$/);
    expect(files).not.toContain('session.json');
    expect(files).not.toContain('entries.jsonl');

    const sessionFile = session.sessionFile ?? path.join(tempRoot, files[0]!);
    const lines = (await readFile(sessionFile, 'utf8')).trim().split('\n');

    expect(lines).toHaveLength(3);

    const header = JSON.parse(lines[0]!);
    const root = JSON.parse(lines[1]!);
    const leaf = JSON.parse(lines[2]!);

    expect(header).toMatchObject({
      type: 'session',
      version: 1,
      id: 'session_file_format',
      timestamp: '2026-04-19T00:00:00.000Z',
      cwd: 'C:/workspace/neta',
    });
    expect(header.entryId).toBeUndefined();
    expect(header.parentEntryId).toBeUndefined();
    expect(root).toMatchObject({
      id: 'entry_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
    });
    expect(leaf).toMatchObject({
      id: 'entry_leaf',
      parentId: 'entry_root',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'custom_message',
    });
  });

  it('reloads session, entries, and persisted active leaf from the JSONL file', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const session = await provider.createSession({
      sessionId: 'session_reload',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    const root = await provider.appendEntry(session.sessionId, {
      id: 'entry_reload_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      message: {
        role: 'user',
        content: 'root',
      },
    });

    const branchA = await provider.appendEntry(session.sessionId, {
      id: 'entry_reload_a',
      parentId: root.id,
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'branch-a',
      },
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_reload_b',
      parentId: root.id,
      timestamp: '2026-04-19T00:03:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'branch-b',
      },
    });

    await provider.switchLeaf(session.sessionId, branchA.id);

    const reloadedProvider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:10:00.000Z',
    });

    const reloadedSession = await reloadedProvider.getSession(session.sessionId);
    const reloadedEntries = await reloadedProvider.listEntries(session.sessionId);
    const activePath = await reloadedProvider.getActivePath(session.sessionId);

    expect(reloadedSession).toMatchObject({
      sessionId: session.sessionId,
      rootEntryId: root.id,
      leafEntryId: branchA.id,
      status: 'active',
      cwd: 'C:/workspace/neta',
    });
    expect(reloadedEntries.map(entry => entry.id)).toEqual([
      root.id,
      branchA.id,
      'entry_reload_b',
    ]);
    expect(activePath.map(entry => entry.id)).toEqual([
      root.id,
      branchA.id,
    ]);

    const lines = (await readFile(reloadedSession?.sessionFile as string, 'utf8')).trim().split('\n');
    const header = JSON.parse(lines[0]!);
    expect(header.leafId).toBe(branchA.id);
  });

  it('rewrite operations preserve existing entry line content while updating the header', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const session = await provider.createSession({
      sessionId: 'session_append_friendly',
      provider: 'file',
    });

    const root = await provider.appendEntry(session.sessionId, {
      id: 'entry_append_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      message: {
        role: 'user',
        content: 'root',
      },
    });

    const beforeSecondAppend = await readFile(session.sessionFile as string, 'utf8');

    const leaf = await provider.appendEntry(session.sessionId, {
      id: 'entry_append_leaf',
      parentId: root.id,
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'leaf',
      },
    });

    const afterSecondAppend = await readFile(session.sessionFile as string, 'utf8');
    expect(afterSecondAppend.trim().split('\n')[1]).toBe(beforeSecondAppend.trim().split('\n')[1]);

    await provider.switchLeaf(session.sessionId, root.id);

    const afterSwitchLeaf = await readFile(session.sessionFile as string, 'utf8');
    const appendLines = afterSecondAppend.trim().split('\n').slice(1);
    const switchLines = afterSwitchLeaf.trim().split('\n').slice(1);

    expect(switchLines).toEqual(appendLines);
    expect(JSON.parse(afterSwitchLeaf.trim().split('\n')[0]!).leafId).toBe(root.id);
    expect(leaf.id).toBe('entry_append_leaf');
  });

  it('persists the active leaf after appendEntry even when a previous switchLeaf wrote header.leafId', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const session = await provider.createSession({
      sessionId: 'session_leaf_persist_after_append',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    const root = await provider.appendEntry(session.sessionId, {
      id: 'entry_leaf_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      message: {
        role: 'user',
        content: 'root',
      },
    });

    const branchA = await provider.appendEntry(session.sessionId, {
      id: 'entry_leaf_branch_a',
      parentId: root.id,
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'branch-a',
      },
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_leaf_branch_b',
      parentId: root.id,
      timestamp: '2026-04-19T00:03:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'branch-b',
      },
    });

    await provider.switchLeaf(session.sessionId, branchA.id);

    const providerAfterSwitch = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:10:00.000Z',
    });

    const branchAChild = await providerAfterSwitch.appendEntry(session.sessionId, {
      id: 'entry_leaf_branch_a_child',
      parentId: branchA.id,
      timestamp: '2026-04-19T00:04:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'branch-a-child',
      },
    });

    const reloadedProvider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:11:00.000Z',
    });

    const reloadedSession = await reloadedProvider.getSession(session.sessionId);
    const activePath = await reloadedProvider.getActivePath(session.sessionId);
    const snapshot = await reloadedProvider.getSnapshot(session.sessionId);

    expect(reloadedSession?.leafEntryId).toBe(branchAChild.id);
    expect(activePath.map(entry => entry.id)).toEqual([
      root.id,
      branchA.id,
      branchAChild.id,
    ]);
    expect(snapshot.activePath.map(entry => entry.id)).toEqual([
      root.id,
      branchA.id,
      branchAChild.id,
    ]);

    const lines = (await readFile(reloadedSession?.sessionFile as string, 'utf8')).trim().split('\n');
    const header = JSON.parse(lines[0]!);
    expect(header.leafId).toBe(branchAChild.id);
    expect(header.updatedAt).toBe('2026-04-19T00:10:00.000Z');
  });

  it('advances the leaf on appendEntry after switchLeaf(null) instead of preserving a null leaf', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const session = await provider.createSession({
      sessionId: 'session_null_leaf_append',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    const root = await provider.appendEntry(session.sessionId, {
      id: 'entry_null_leaf_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      message: {
        role: 'user',
        content: 'root',
      },
    });

    await provider.switchLeaf(session.sessionId, null);

    const child = await provider.appendEntry(session.sessionId, {
      id: 'entry_null_leaf_child',
      parentId: root.id,
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'message',
      message: {
        role: 'assistant',
        content: 'child',
      },
    });

    const reloadedProvider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:05:00.000Z',
    });

    const reloadedSession = await reloadedProvider.getSession(session.sessionId);
    const activePath = await reloadedProvider.getActivePath(session.sessionId);
    const snapshot = await reloadedProvider.getSnapshot(session.sessionId);

    expect(reloadedSession?.leafEntryId).toBe(child.id);
    expect(activePath.map(entry => entry.id)).toEqual([root.id, child.id]);
    expect(snapshot.activePath.map(entry => entry.id)).toEqual([root.id, child.id]);
  });

  it('throws SessionTreeProviderError with file path and line context for malformed JSONL files', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const emptyFile = provider.getSessionFilePath('session_empty');
    await writeFile(emptyFile, '', 'utf8');

    await expect(provider.getSession('session_empty')).rejects.toThrow(
      expect.objectContaining({
        name: 'SessionTreeProviderError',
        message: expect.stringContaining(`${emptyFile}`),
      }),
    );
    await expect(provider.getSession('session_empty')).rejects.toThrow(/empty/i);

    const badHeaderJsonFile = provider.getSessionFilePath('session_bad_header_json');
    await writeFile(badHeaderJsonFile, '{"type":"session"\n', 'utf8');

    await expect(provider.getSession('session_bad_header_json')).rejects.toThrow(
      expect.objectContaining({
        name: 'SessionTreeProviderError',
        message: expect.stringContaining(`${badHeaderJsonFile}`),
      }),
    );
    await expect(provider.getSession('session_bad_header_json')).rejects.toThrow(/line 1/i);

    const badEntryJsonFile = provider.getSessionFilePath('session_bad_entry_json');
    await writeFile(
      badEntryJsonFile,
      `${JSON.stringify({
        type: 'session',
        version: 1,
        id: 'session_bad_entry_json',
        timestamp: '2026-04-19T00:00:00.000Z',
        cwd: 'C:/workspace/neta',
      })}\n{"id":"entry_broken"\n`,
      'utf8',
    );

    await expect(provider.getSession('session_bad_entry_json')).rejects.toThrow(
      expect.objectContaining({
        name: 'SessionTreeProviderError',
        message: expect.stringContaining(`${badEntryJsonFile}`),
      }),
    );
    await expect(provider.getSession('session_bad_entry_json')).rejects.toThrow(/line 2/i);
  });

  it('rejects leading blank lines instead of skipping them before the header', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const leadingBlankFile = provider.getSessionFilePath('session_leading_blank');
    await writeFile(
      leadingBlankFile,
      `\n${JSON.stringify({
        type: 'session',
        version: 1,
        id: 'session_leading_blank',
        timestamp: '2026-04-19T00:00:00.000Z',
        cwd: 'C:/workspace/neta',
      })}\n`,
      'utf8',
    );

    await expect(provider.getSession('session_leading_blank')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.getSession('session_leading_blank')).rejects.toThrow(/line 1/i);
  });

  it('rejects blank lines between entries while allowing trailing blank lines', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const blankBetweenEntriesFile = provider.getSessionFilePath('session_blank_between_entries');
    await writeFile(
      blankBetweenEntriesFile,
      `${JSON.stringify({
        type: 'session',
        version: 1,
        id: 'session_blank_between_entries',
        timestamp: '2026-04-19T00:00:00.000Z',
        cwd: 'C:/workspace/neta',
      })}\n${JSON.stringify({
        id: 'entry_root',
        parentId: null,
        timestamp: '2026-04-19T00:01:00.000Z',
        type: 'message',
        message: {
          role: 'user',
          content: 'root',
        },
      })}\n\n${JSON.stringify({
        id: 'entry_child',
        parentId: 'entry_root',
        timestamp: '2026-04-19T00:02:00.000Z',
        type: 'message',
        message: {
          role: 'assistant',
          content: 'child',
        },
      })}\n`,
      'utf8',
    );

    await expect(provider.getSession('session_blank_between_entries')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.getSession('session_blank_between_entries')).rejects.toThrow(/line 3/i);

    const trailingBlankFile = provider.getSessionFilePath('session_trailing_blank');
    await writeFile(
      trailingBlankFile,
      `${JSON.stringify({
        type: 'session',
        version: 1,
        id: 'session_trailing_blank',
        timestamp: '2026-04-19T00:00:00.000Z',
        cwd: 'C:/workspace/neta',
      })}\n${JSON.stringify({
        id: 'entry_root',
        parentId: null,
        timestamp: '2026-04-19T00:01:00.000Z',
        type: 'message',
        message: {
          role: 'user',
          content: 'root',
        },
      })}\n\n`,
      'utf8',
    );

    const session = await provider.getSession('session_trailing_blank');
    expect(session?.sessionId).toBe('session_trailing_blank');
  });

  it('reports malformed entry JSON with the physical line number after blank lines', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const badEntryAfterBlankFile = provider.getSessionFilePath('session_bad_entry_after_blank');
    await writeFile(
      badEntryAfterBlankFile,
      `${JSON.stringify({
        type: 'session',
        version: 1,
        id: 'session_bad_entry_after_blank',
        timestamp: '2026-04-19T00:00:00.000Z',
        cwd: 'C:/workspace/neta',
      })}\n${JSON.stringify({
        id: 'entry_root',
        parentId: null,
        timestamp: '2026-04-19T00:01:00.000Z',
        type: 'message',
        message: {
          role: 'user',
          content: 'root',
        },
      })}\n\n{"id":"entry_broken"\n`,
      'utf8',
    );

    await expect(provider.listEntries('session_bad_entry_after_blank')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.listEntries('session_bad_entry_after_blank')).rejects.toThrow(/line 4/i);
  });

  it('throws SessionTreeProviderError when the header is missing required base fields or sessionId mismatches', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const invalidHeaders: Array<{
      sessionId: string;
      header: Record<string, unknown>;
      expected: RegExp;
    }> = [
      {
        sessionId: 'session_missing_type',
        header: {
          version: 1,
          id: 'session_missing_type',
          timestamp: '2026-04-19T00:00:00.000Z',
          cwd: 'C:/workspace/neta',
        },
        expected: /type/i,
      },
      {
        sessionId: 'session_missing_version',
        header: {
          type: 'session',
          id: 'session_missing_version',
          timestamp: '2026-04-19T00:00:00.000Z',
          cwd: 'C:/workspace/neta',
        },
        expected: /version/i,
      },
      {
        sessionId: 'session_missing_id',
        header: {
          type: 'session',
          version: 1,
          timestamp: '2026-04-19T00:00:00.000Z',
          cwd: 'C:/workspace/neta',
        },
        expected: /id/i,
      },
      {
        sessionId: 'session_missing_timestamp',
        header: {
          type: 'session',
          version: 1,
          id: 'session_missing_timestamp',
          cwd: 'C:/workspace/neta',
        },
        expected: /timestamp/i,
      },
      {
        sessionId: 'session_missing_cwd',
        header: {
          type: 'session',
          version: 1,
          id: 'session_missing_cwd',
          timestamp: '2026-04-19T00:00:00.000Z',
        },
        expected: /cwd/i,
      },
    ];

    for (const { sessionId, header, expected } of invalidHeaders) {
      const file = provider.getSessionFilePath(sessionId);
      await writeFile(file, `${JSON.stringify(header)}\n`, 'utf8');

      await expect(provider.getSession(sessionId)).rejects.toThrow(
        expect.objectContaining({
          name: 'SessionTreeProviderError',
          message: expect.stringContaining(file),
        }),
      );
      await expect(provider.getSession(sessionId)).rejects.toThrow(expected);
      await expect(provider.getSession(sessionId)).rejects.toThrow(/line 1/i);
    }

    const mismatchedIdFile = provider.getSessionFilePath('session_requested_id');
    await writeFile(
      mismatchedIdFile,
      `${JSON.stringify({
        type: 'session',
        version: 1,
        id: 'session_actual_id',
        timestamp: '2026-04-19T00:00:00.000Z',
        cwd: 'C:/workspace/neta',
      })}\n`,
      'utf8',
    );

    await expect(provider.getSession('session_requested_id')).rejects.toThrow(
      expect.objectContaining({
        name: 'SessionTreeProviderError',
        message: expect.stringContaining(mismatchedIdFile),
      }),
    );
    await expect(provider.getSession('session_requested_id')).rejects.toThrow(/session_requested_id/);
    await expect(provider.getSession('session_requested_id')).rejects.toThrow(/session_actual_id/);
  });

  it('maps session ids to safe stable base64url file names while preserving the original header id', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const conSession = await provider.createSession({
      sessionId: 'CON',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    const complexSessionId = 'folder/name:with:colon';
    const complexSession = await provider.createSession({
      sessionId: complexSessionId,
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    const files = (await readdir(tempRoot)).sort();
    expect(files).toHaveLength(2);
    expect(files).toEqual([
      'Q09O.jsonl',
      'Zm9sZGVyL25hbWU6d2l0aDpjb2xvbg.jsonl',
    ]);

    expect(path.basename(conSession.sessionFile as string)).toBe('Q09O.jsonl');
    expect(path.basename(complexSession.sessionFile as string)).toBe('Zm9sZGVyL25hbWU6d2l0aDpjb2xvbg.jsonl');

    const conHeader = JSON.parse((await readFile(conSession.sessionFile as string, 'utf8')).trim().split('\n')[0]!);
    const complexHeader = JSON.parse((await readFile(complexSession.sessionFile as string, 'utf8')).trim().split('\n')[0]!);

    expect(conHeader.id).toBe('CON');
    expect(complexHeader.id).toBe(complexSessionId);
  });

  it('rejects non-JSON-serializable input at file provider boundaries while preserving undefined clear semantics', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    await expect(
      provider.createSession({
        sessionId: 'session_bad_metadata',
        provider: 'file',
        metadata: {
          invalid: Symbol('bad'),
        } as unknown as Record<string, unknown>,
      }),
    ).rejects.toBeInstanceOf(SessionTreeProviderError);

    const session = await provider.createSession({
      sessionId: 'session_json_serializable',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    await expect(
      provider.appendEntry(session.sessionId, {
        id: 'entry_non_json_append',
        parentId: null,
        timestamp: '2026-04-19T00:01:00.000Z',
        type: 'custom_message',
        customType: 'note',
        content: {
          invalid: BigInt(1),
        },
        display: true,
      }),
    ).rejects.toBeInstanceOf(SessionTreeProviderError);

    const label = await provider.appendEntry(session.sessionId, {
      id: 'entry_label_clear_semantics',
      parentId: null,
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'label',
      targetId: 'entry_target',
      label: undefined,
    });
    if (label.type !== 'label') {
      throw new Error('expected label entry');
    }
    expect(label.label).toBeUndefined();

    await expect(
      provider.updateEntry(session.sessionId, label.id, {
        metadata: {
          invalid: () => 'nope',
        } as unknown as Record<string, unknown>,
      }),
    ).rejects.toBeInstanceOf(SessionTreeProviderError);
  });

  it('rejects non-JSON-serializable updateSession metadata with SessionTreeProviderError', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    const invalidMetadataCases: Array<{
      name: string;
      metadata: Record<string, unknown>;
    }> = [
      {
        name: 'date',
        metadata: new Date('2026-04-19T00:00:00.000Z') as unknown as Record<string, unknown>,
      },
      {
        name: 'symbol',
        metadata: {
          invalid: Symbol('bad'),
        },
      },
      {
        name: 'bigint',
        metadata: {
          invalid: BigInt(1),
        },
      },
      {
        name: 'function',
        metadata: {
          invalid: () => 'bad',
        },
      },
      {
        name: 'non_plain_object',
        metadata: {
          invalid: new (class CustomMetadata {
            value = 'bad';
          })(),
        },
      },
    ];

    for (const { name, metadata } of invalidMetadataCases) {
      const session = await provider.createSession({
        sessionId: `session_update_metadata_${name}`,
        provider: 'file',
        cwd: 'C:/workspace/neta',
      });

      await expect(
        provider.updateSession(session.sessionId, {
          metadata,
        }),
      ).rejects.toBeInstanceOf(SessionTreeProviderError);
    }
  });

  it('rewrites the target entry line and updates the header timestamp for updateEntry', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:10:00.000Z',
    });

    const session = await provider.createSession({
      sessionId: 'session_update_entry_rewrite',
      provider: 'file',
      cwd: 'C:/workspace/neta',
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_update_root',
      parentId: null,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'custom_message',
      customType: 'notice',
      content: {
        text: 'before',
      },
      details: {
        stage: 'before',
      },
      display: true,
    });

    await provider.updateEntry(session.sessionId, 'entry_update_root', {
      content: {
        text: 'after',
      },
      details: {
        stage: 'after',
      },
      display: false,
    });

    const lines = (await readFile(session.sessionFile as string, 'utf8')).trim().split('\n');
    const header = JSON.parse(lines[0]!);
    const entry = JSON.parse(lines[1]!);

    expect(header.updatedAt).toBe('2026-04-19T00:10:00.000Z');
    expect(entry).toMatchObject({
      id: 'entry_update_root',
      content: {
        text: 'after',
      },
      details: {
        stage: 'after',
      },
      display: false,
    });
  });

  it('returns null for missing getSession and throws SessionTreeProviderError for other require-session APIs', async () => {
    const provider = new FileSessionTreeProvider({
      rootDir: tempRoot,
      cwd: () => 'C:/workspace/neta',
      now: () => '2026-04-19T00:00:00.000Z',
    });

    expect(await provider.getSession('session_missing')).toBeNull();

    await expect(provider.listEntries('session_missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.getActivePath('session_missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.getSnapshot('session_missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(
      provider.updateEntry('session_missing', 'entry_missing', {
        metadata: {
          test: true,
        },
      }),
    ).rejects.toBeInstanceOf(SessionTreeProviderError);
  });
});
