import { NetaClawAgentSessionEntryEntity } from '../src/modules/netaclaw/entity/agent_session_entry.js';
import { NetaClawAgentSessionEntity } from '../src/modules/netaclaw/entity/agent_session.js';
import { MySqlSessionTreeProvider } from '../src/modules/netaclaw/session-tree/mysql_provider.js';
import { SessionTreeProviderError } from '../src/modules/netaclaw/session-tree/provider.js';
import type {
  SessionTreeEntry,
  SessionTreeSession,
} from '../src/modules/netaclaw/session-tree/types.js';
import { runSessionTreeProviderContract } from './session_tree_provider_contract.test.js';

type SortDirection = 'ASC' | 'DESC' | 'asc' | 'desc';

type RepoFindOptions<T> = {
  where?: Partial<T>;
  order?: Partial<Record<keyof T, SortDirection>>;
};

interface MockRepository<T extends object> {
  find(options?: RepoFindOptions<T>): Promise<T[]>;
  findOne(options: { where: Partial<T> }): Promise<T | null>;
  findOneBy(where: Partial<T>): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(where: Partial<T>): Promise<void>;
  allRows(): T[];
}

interface ProviderFixture {
  provider: MySqlSessionTreeProvider;
  sessionRepo: MockRepository<NetaClawAgentSessionEntity>;
  entryRepo: MockRepository<NetaClawAgentSessionEntryEntity>;
}

class NonPlainRecord {
  constructor(public readonly value: string) {}
}

function createRepositoryMock<T extends { id?: number } & object>(): MockRepository<T> {
  const rows: T[] = [];
  let nextId = 1;

  return {
    async find(options?: RepoFindOptions<T>): Promise<T[]> {
      let result = rows.filter(row => matchesWhere(row, options?.where));
      result = applyOrder(result, options?.order);
      return result.map(cloneValue);
    },

    async findOne(options: { where: Partial<T> }): Promise<T | null> {
      return cloneMaybe(rows.find(row => matchesWhere(row, options.where)) ?? null);
    },

    async findOneBy(where: Partial<T>): Promise<T | null> {
      return cloneMaybe(rows.find(row => matchesWhere(row, where)) ?? null);
    },

    async save(entity: T): Promise<T> {
      const cloned = cloneValue(entity);
      const entityId = typeof cloned.id === 'number' ? cloned.id : undefined;

      if (entityId !== undefined) {
        const existingIndex = rows.findIndex(row => row.id === entityId);

        if (existingIndex >= 0) {
          rows[existingIndex] = cloned;
        } else {
          assertNoDuplicateBusinessKey(rows, cloned);
          rows.push(cloned);
          nextId = Math.max(nextId, entityId + 1);
        }

        return cloneValue(cloned);
      }

      assertNoDuplicateBusinessKey(rows, cloned);
      cloned.id = nextId++;
      rows.push(cloned);

      return cloneValue(cloned);
    },

    async delete(where: Partial<T>): Promise<void> {
      for (let index = rows.length - 1; index >= 0; index -= 1) {
        if (matchesWhere(rows[index]!, where)) {
          rows.splice(index, 1);
        }
      }
    },

    allRows(): T[] {
      return rows.map(cloneValue);
    },
  };
}

function applyOrder<T extends object>(
  rows: T[],
  order: Partial<Record<keyof T, SortDirection>> | undefined,
): T[] {
  if (!order || Object.keys(order).length === 0) {
    return [...rows];
  }

  const entries = Object.entries(order) as Array<[keyof T, SortDirection]>;
  return [...rows].sort((left, right) => {
    for (const [key, direction] of entries) {
      const normalized = String(direction).toUpperCase() === 'DESC' ? -1 : 1;
      const leftValue = left[key];
      const rightValue = right[key];

      if (leftValue === rightValue) {
        continue;
      }

      if (leftValue === undefined || leftValue === null) {
        return -1 * normalized;
      }

      if (rightValue === undefined || rightValue === null) {
        return 1 * normalized;
      }

      if (leftValue < rightValue) {
        return -1 * normalized;
      }

      if (leftValue > rightValue) {
        return 1 * normalized;
      }
    }

    return 0;
  });
}

function matchesWhere<T extends object>(row: T, where: Partial<T> | undefined): boolean {
  if (!where) {
    return true;
  }

  return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}

function hasKey<K extends string>(value: object, key: K): value is object & Record<K, unknown> {
  return key in value;
}

function assertNoDuplicateBusinessKey<T extends object>(rows: T[], entity: T): void {
  if (rows.some(row => isSameBusinessRow(row, entity))) {
    throw new Error(`Duplicate business key insert without primary key: ${describeBusinessKey(entity)}`);
  }
}

function isSameBusinessRow<T extends object>(left: T, right: T): boolean {
  if (hasKey(left, 'entryId') && hasKey(right, 'entryId') && hasKey(left, 'sessionId') && hasKey(right, 'sessionId')) {
    return left.entryId === right.entryId && left.sessionId === right.sessionId;
  }

  if (hasKey(left, 'sessionId') && hasKey(right, 'sessionId')) {
    return left.sessionId === right.sessionId;
  }

  return false;
}

function describeBusinessKey<T extends object>(entity: T): string {
  if (hasKey(entity, 'entryId') && hasKey(entity, 'sessionId')) {
    return `sessionId=${String(entity.sessionId)}, entryId=${String(entity.entryId)}`;
  }

  if (hasKey(entity, 'sessionId')) {
    return `sessionId=${String(entity.sessionId)}`;
  }

  return 'unknown';
}

function cloneMaybe<T>(value: T | null): T | null {
  return value === null ? null : cloneValue(value);
}

function cloneValue<T>(value: T): T {
  return JSON.parse(JSON.stringify(value)) as T;
}

function createFixture(): ProviderFixture {
  const sessionRepo = createRepositoryMock<NetaClawAgentSessionEntity>();
  const entryRepo = createRepositoryMock<NetaClawAgentSessionEntryEntity>();

  return {
    sessionRepo,
    entryRepo,
    provider: new MySqlSessionTreeProvider({
      sessionRepo,
      entryRepo,
      now: () => '2026-04-19T00:00:00.000Z',
      sessionIdGenerator: () => 'session_generated',
      entryIdGenerator: () => 'entry_generated',
    }),
  };
}

function seedSessionRow(
  value: Omit<
    Partial<NetaClawAgentSessionEntity>,
    'sessionId' | 'provider' | 'rootEntryId' | 'leafEntryId' | 'createTime' | 'updateTime'
  > & {
    sessionId: string;
    provider: string;
    rootEntryId: string | null;
    leafEntryId: string | null;
    createTime?: string | Date;
    updateTime?: string | Date;
  },
): NetaClawAgentSessionEntity {
  return value as unknown as NetaClawAgentSessionEntity;
}

function seedEntryRow(
  value: Omit<
    Partial<NetaClawAgentSessionEntryEntity>,
    'sessionId' | 'entryId' | 'parentEntryId' | 'timestamp' | 'type' | 'createTime' | 'updateTime'
  > & {
    sessionId: string;
    entryId: string;
    parentEntryId: string | null;
    timestamp: string;
    type: string;
    createTime?: string | Date;
    updateTime?: string | Date;
  },
): NetaClawAgentSessionEntryEntity {
  return value as unknown as NetaClawAgentSessionEntryEntity;
}

describe('MySqlSessionTreeProvider', () => {
  runSessionTreeProviderContract('mysql', () => createFixture().provider);

  it('createSession maps defaults into mysql-backed session state', async () => {
    const { provider, sessionRepo } = createFixture();

    const session = await provider.createSession({
      sessionId: 'session_mysql_smoke',
      cwd: 'C:/workspace/neta',
      metadata: {
        source: 'test',
      },
    });

    expect(session).toEqual({
      sessionId: 'session_mysql_smoke',
      provider: 'mysql',
      rootEntryId: null,
      leafEntryId: null,
      cwd: 'C:/workspace/neta',
      sessionFile: undefined,
      parentSessionId: undefined,
      agentId: undefined,
      userId: undefined,
      title: undefined,
      status: 'active',
      metadata: {
        source: 'test',
      },
      createdAt: '2026-04-19T00:00:00.000Z',
      updatedAt: '2026-04-19T00:00:00.000Z',
    });

    const saved = await sessionRepo.findOneBy({
      sessionId: 'session_mysql_smoke',
    });
    expect(saved).toMatchObject({
      sessionId: 'session_mysql_smoke',
      provider: 'mysql',
      rootEntryId: null,
      leafEntryId: null,
      cwd: 'C:/workspace/neta',
      status: 'active',
      metadata: {
        source: 'test',
      },
    });
  });

  it('maps mysql entry entities back to Pi-first ids and keeps stable list order', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();

    await sessionRepo.save(seedSessionRow({
      sessionId: 'session_mapping',
      provider: 'mysql',
      rootEntryId: 'entry_root',
      leafEntryId: 'entry_child_b',
      cwd: 'C:/workspace/neta',
      sessionFile: null,
      parentSessionId: null,
      agentId: null,
      userId: null,
      title: null,
      status: 'active',
      metadata: {
        source: 'seed',
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_mapping',
      entryId: 'entry_child_b',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      metadata: {
        branch: 'b',
      },
      payload: {
        message: {
          role: 'assistant',
          content: 'child-b',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_mapping',
      entryId: 'entry_root',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      metadata: null,
      payload: {
        message: {
          role: 'user',
          content: 'root',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_mapping',
      entryId: 'entry_child_a',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      metadata: {
        branch: 'a',
      },
      payload: {
        message: {
          role: 'assistant',
          content: 'child-a',
        },
      },
    }));

    const session = await provider.getSession('session_mapping');
    const entries = await provider.listEntries('session_mapping');
    const activePath = await provider.getActivePath('session_mapping');
    const snapshot = await provider.getSnapshot('session_mapping');

    expect(session).toMatchObject({
      sessionId: 'session_mapping',
      rootEntryId: 'entry_root',
      leafEntryId: 'entry_child_b',
      provider: 'mysql',
    });
    expect(entries.map(entry => entry.id)).toEqual([
      'entry_root',
      'entry_child_a',
      'entry_child_b',
    ]);
    expect(entries[0]).toMatchObject({
      id: 'entry_root',
      parentId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
    });
    expect(activePath.map(entry => entry.id)).toEqual([
      'entry_root',
      'entry_child_b',
    ]);
    expect(snapshot.activePath.map(entry => entry.id)).toEqual([
      'entry_root',
      'entry_child_b',
    ]);
  });

  it('derives public rootEntryId from entries instead of trusting the stored session row', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();

    await sessionRepo.save(seedSessionRow({
      sessionId: 'session_wrong_root',
      provider: 'mysql',
      rootEntryId: 'entry_wrong_root',
      leafEntryId: 'entry_child',
      status: 'active',
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_wrong_root',
      entryId: 'entry_root',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'user',
          content: 'root',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_wrong_root',
      entryId: 'entry_child',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'assistant',
          content: 'child',
        },
      },
    }));

    await expect(provider.getSession('session_wrong_root')).resolves.toMatchObject({
      sessionId: 'session_wrong_root',
      rootEntryId: 'entry_root',
      leafEntryId: 'entry_child',
    });
  });

  it('falls back to the last entry when the stored leafEntryId points to a missing entry', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();

    await sessionRepo.save(seedSessionRow({
      sessionId: 'session_wrong_leaf',
      provider: 'mysql',
      rootEntryId: 'entry_root',
      leafEntryId: 'entry_missing',
      status: 'active',
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_wrong_leaf',
      entryId: 'entry_root',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'user',
          content: 'root',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_wrong_leaf',
      entryId: 'entry_child_a',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'assistant',
          content: 'child-a',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_wrong_leaf',
      entryId: 'entry_child_b',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'assistant',
          content: 'child-b',
        },
      },
    }));

    await expect(provider.getSession('session_wrong_leaf')).resolves.toMatchObject({
      sessionId: 'session_wrong_leaf',
      rootEntryId: 'entry_root',
      leafEntryId: 'entry_child_b',
    });
    await expect(provider.getActivePath('session_wrong_leaf')).resolves.toMatchObject([
      { id: 'entry_root' },
      { id: 'entry_child_b' },
    ]);
  });

  it('preserves an explicit null leafEntryId and returns an empty active path', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();

    await sessionRepo.save(seedSessionRow({
      sessionId: 'session_null_leaf',
      provider: 'mysql',
      rootEntryId: 'entry_wrong_root',
      leafEntryId: null,
      status: 'active',
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_null_leaf',
      entryId: 'entry_root',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'user',
          content: 'root',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_null_leaf',
      entryId: 'entry_child',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'assistant',
          content: 'child',
        },
      },
    }));

    await expect(provider.getSession('session_null_leaf')).resolves.toMatchObject({
      sessionId: 'session_null_leaf',
      rootEntryId: 'entry_root',
      leafEntryId: null,
    });
    await expect(provider.getActivePath('session_null_leaf')).resolves.toEqual([]);
  });

  it('builds snapshots from the normalized session view instead of raw mysql row pointers', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();

    await sessionRepo.save(seedSessionRow({
      sessionId: 'session_snapshot_normalized',
      provider: 'mysql',
      rootEntryId: 'entry_wrong_root',
      leafEntryId: 'entry_missing',
      status: 'active',
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_snapshot_normalized',
      entryId: 'entry_root',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'user',
          content: 'root',
        },
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_snapshot_normalized',
      entryId: 'entry_child',
      parentEntryId: 'entry_root',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'assistant',
          content: 'child',
        },
      },
    }));

    const snapshot = await provider.getSnapshot('session_snapshot_normalized');

    expect(snapshot.session).toMatchObject({
      sessionId: 'session_snapshot_normalized',
      rootEntryId: 'entry_root',
      leafEntryId: 'entry_child',
    });
    expect(snapshot.activePath.map(entry => entry.id)).toEqual([
      'entry_root',
      'entry_child',
    ]);
  });

  it('appendEntry persists mysql entity fields and payload split, then updateEntry respects whitelist mapping', async () => {
    const { provider, entryRepo } = createFixture();
    const session = await provider.createSession({
      sessionId: 'session_update_mapping',
      cwd: 'C:/workspace/neta',
    });

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

    const appendedRow = await entryRepo.findOneBy({
      sessionId: session.sessionId,
      entryId: 'entry_root',
    });
    expect(appendedRow).toMatchObject({
      sessionId: session.sessionId,
      entryId: 'entry_root',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'custom_message',
      metadata: {
        source: 'append',
      },
      payload: {
        customType: 'notice',
        content: {
          text: 'before',
        },
        details: {
          stage: 'before',
        },
        display: true,
      },
    });

    const updated = await provider.updateEntry(session.sessionId, 'entry_root', {
      content: {
        text: 'after',
      },
      details: {
        stage: 'after',
      },
      display: false,
      metadata: {
        source: 'update',
      },
    });

    expect(updated).toMatchObject({
      id: 'entry_root',
      parentId: null,
      type: 'custom_message',
      customType: 'notice',
      content: {
        text: 'after',
      },
      details: {
        stage: 'after',
      },
      display: false,
      metadata: {
        source: 'update',
      },
    });

    const updatedRow = await entryRepo.findOneBy({
      sessionId: session.sessionId,
      entryId: 'entry_root',
    });
    expect(updatedRow?.payload).toEqual({
      customType: 'notice',
      content: {
        text: 'after',
      },
      details: {
        stage: 'after',
      },
      display: false,
    });

    await expect(provider.updateEntry(session.sessionId, 'entry_root', {
      customType: 'mutated',
    } as never)).rejects.toBeInstanceOf(SessionTreeProviderError);
  });

  it('requires a primary key for updateSession and updateEntry saves instead of business-key upsert semantics', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();
    const session = await provider.createSession({
      sessionId: 'session_real_save_semantics',
      cwd: 'C:/workspace/neta',
      title: 'before',
    });

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

    await expect(provider.updateSession(session.sessionId, {
      title: 'after',
    })).resolves.toMatchObject({
      sessionId: session.sessionId,
      title: 'after',
    });

    await expect(provider.updateEntry(session.sessionId, 'entry_real_save_semantics', {
      content: {
        text: 'after',
      },
    })).resolves.toMatchObject({
      id: 'entry_real_save_semantics',
      type: 'custom_message',
      content: {
        text: 'after',
      },
    });

    expect(sessionRepo.allRows()).toHaveLength(1);
    expect(entryRepo.allRows()).toHaveLength(1);
  });

  it('round-trips non-message entries through mysql payload mapping while preserving Pi shape', async () => {
    const { provider } = createFixture();
    const session = await provider.createSession({
      sessionId: 'session_roundtrip',
    });

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

    await provider.appendEntry(session.sessionId, {
      id: 'entry_roundtrip_model',
      parentId: root.id,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'model_change',
      provider: 'openai',
      modelId: 'gpt-4.1',
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_roundtrip_label',
      parentId: 'entry_roundtrip_model',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'label',
      targetId: root.id,
      label: 'important',
    });

    await provider.appendEntry(session.sessionId, {
      id: 'entry_roundtrip_custom_message',
      parentId: 'entry_roundtrip_label',
      timestamp: '2026-04-19T00:03:00.000Z',
      type: 'custom_message',
      customType: 'notice',
      content: {
        text: 'hello',
      },
      details: {
        from: 'test',
      },
      display: true,
    });

    const entries = await provider.listEntries(session.sessionId);
    const byId = Object.fromEntries(entries.map(entry => [entry.id, entry] satisfies [string, SessionTreeEntry]));

    expect(byId.entry_roundtrip_model).toEqual({
      id: 'entry_roundtrip_model',
      parentId: root.id,
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'model_change',
      provider: 'openai',
      modelId: 'gpt-4.1',
    });
    expect(byId.entry_roundtrip_label).toEqual({
      id: 'entry_roundtrip_label',
      parentId: 'entry_roundtrip_model',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'label',
      targetId: root.id,
      label: 'important',
    });
    expect(byId.entry_roundtrip_custom_message).toEqual({
      id: 'entry_roundtrip_custom_message',
      parentId: 'entry_roundtrip_label',
      timestamp: '2026-04-19T00:03:00.000Z',
      type: 'custom_message',
      customType: 'notice',
      content: {
        text: 'hello',
      },
      details: {
        from: 'test',
      },
      display: true,
    });
  });

  it('round-trips full mysql row payloads including undefined label clearing and time normalization', async () => {
    const { provider, sessionRepo, entryRepo } = createFixture();
    await provider.createSession({
      sessionId: 'session_roundtrip_full',
      cwd: 'C:/workspace/neta',
      agentId: 42,
      userId: 'user-7',
      title: 'Roundtrip',
      metadata: {
        source: 'seed',
      },
    });

    const storedSession = await sessionRepo.findOneBy({
      sessionId: 'session_roundtrip_full',
    });
    if (!storedSession) {
      throw new Error('expected stored session row');
    }

    await sessionRepo.save(seedSessionRow({
      ...storedSession,
      sessionId: 'session_roundtrip_full',
      provider: 'mysql',
      rootEntryId: 'entry_message',
      leafEntryId: 'entry_branch_summary',
      createTime: '2026-04-19 00:00:00',
      updateTime: new Date('2026-04-19T00:09:00.000Z'),
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_roundtrip_full',
      entryId: 'entry_message',
      parentEntryId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      payload: {
        message: {
          role: 'assistant',
          content: {
            text: 'hello',
          },
          metadata: {
            trace: 1,
          },
        },
      },
      metadata: {
        note: 'message',
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_roundtrip_full',
      entryId: 'entry_label_clear',
      parentEntryId: 'entry_message',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'label',
      payload: {
        targetId: 'entry_message',
      },
      metadata: null,
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_roundtrip_full',
      entryId: 'entry_custom',
      parentEntryId: 'entry_label_clear',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'custom',
      payload: {
        customType: 'artifact',
        data: {
          format: 'json',
        },
      },
      metadata: null,
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_roundtrip_full',
      entryId: 'entry_session_info',
      parentEntryId: 'entry_custom',
      timestamp: '2026-04-19T00:03:00.000Z',
      type: 'session_info',
      payload: {
        name: 'Session name',
      },
      metadata: null,
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_roundtrip_full',
      entryId: 'entry_compaction',
      parentEntryId: 'entry_session_info',
      timestamp: '2026-04-19T00:04:00.000Z',
      type: 'compaction',
      payload: {
        summary: 'Compacted',
        firstKeptEntryId: 'entry_message',
        tokensBefore: 128,
        details: {
          reason: 'trim',
        },
        fromHook: false,
      },
      metadata: {
        source: 'hook',
      },
    }));

    await entryRepo.save(seedEntryRow({
      sessionId: 'session_roundtrip_full',
      entryId: 'entry_branch_summary',
      parentEntryId: 'entry_compaction',
      timestamp: '2026-04-19T00:05:00.000Z',
      type: 'branch_summary',
      payload: {
        fromId: 'entry_custom',
        summary: 'Branch summary',
        details: {
          branch: 'left',
        },
        fromHook: true,
      },
      metadata: null,
    }));

    const loadedSession = await provider.getSession('session_roundtrip_full');
    const loadedEntries = await provider.listEntries('session_roundtrip_full');
    const byId = Object.fromEntries(loadedEntries.map(entry => [entry.id, entry] satisfies [string, SessionTreeEntry]));

    expect(loadedSession).toEqual({
      sessionId: 'session_roundtrip_full',
      provider: 'mysql',
      rootEntryId: 'entry_message',
      leafEntryId: 'entry_branch_summary',
      cwd: 'C:/workspace/neta',
      sessionFile: undefined,
      parentSessionId: undefined,
      agentId: 42,
      userId: 'user-7',
      title: 'Roundtrip',
      status: 'active',
      metadata: {
        source: 'seed',
      },
      createdAt: '2026-04-19T00:00:00.000Z',
      updatedAt: '2026-04-19T00:09:00.000Z',
    });

    expect(byId.entry_message).toEqual({
      id: 'entry_message',
      parentId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      metadata: {
        note: 'message',
      },
      message: {
        role: 'assistant',
        content: {
          text: 'hello',
        },
        metadata: {
          trace: 1,
        },
      },
    });
    expect(byId.entry_label_clear).toEqual({
      id: 'entry_label_clear',
      parentId: 'entry_message',
      timestamp: '2026-04-19T00:01:00.000Z',
      type: 'label',
      targetId: 'entry_message',
      label: undefined,
    });
    expect(byId.entry_custom).toEqual({
      id: 'entry_custom',
      parentId: 'entry_label_clear',
      timestamp: '2026-04-19T00:02:00.000Z',
      type: 'custom',
      customType: 'artifact',
      data: {
        format: 'json',
      },
    });
    expect(byId.entry_session_info).toEqual({
      id: 'entry_session_info',
      parentId: 'entry_custom',
      timestamp: '2026-04-19T00:03:00.000Z',
      type: 'session_info',
      name: 'Session name',
    });
    expect(byId.entry_compaction).toEqual({
      id: 'entry_compaction',
      parentId: 'entry_session_info',
      timestamp: '2026-04-19T00:04:00.000Z',
      type: 'compaction',
      metadata: {
        source: 'hook',
      },
      summary: 'Compacted',
      firstKeptEntryId: 'entry_message',
      tokensBefore: 128,
      details: {
        reason: 'trim',
      },
      fromHook: false,
    });
    expect(byId.entry_branch_summary).toEqual({
      id: 'entry_branch_summary',
      parentId: 'entry_compaction',
      timestamp: '2026-04-19T00:05:00.000Z',
      type: 'branch_summary',
      fromId: 'entry_custom',
      summary: 'Branch summary',
      details: {
        branch: 'left',
      },
      fromHook: true,
    });
  });

  it('fails fast on malformed mysql rows instead of loosely coercing payload values', async () => {
    const invalidCases: Array<{
      name: string;
      row: ReturnType<typeof seedEntryRow>;
    }> = [
      {
        name: 'message payload must be a plain object',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_message_shape',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'message',
          payload: {
            message: 'not-an-object' as unknown as Record<string, unknown>,
          },
        }),
      },
      {
        name: 'message role must be a string',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_message_role',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'message',
          payload: {
            message: {
              role: true,
              content: 'x',
            },
          },
        }),
      },
      {
        name: 'message content is required',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_message_content',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'message',
          payload: {
            message: {
              role: 'assistant',
            },
          },
        }),
      },
      {
        name: 'custom_message display must be boolean',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_custom_display',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'custom_message',
          payload: {
            customType: 'notice',
            content: 'hello',
            display: 'false',
          },
        }),
      },
      {
        name: 'compaction fromHook must be boolean',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_compaction_from_hook',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'compaction',
          payload: {
            summary: 's',
            firstKeptEntryId: 'entry_root',
            tokensBefore: 1,
            fromHook: 'true',
          },
        }),
      },
      {
        name: 'branch_summary fromHook must be boolean',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_branch_from_hook',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'branch_summary',
          payload: {
            fromId: 'entry_root',
            summary: 's',
            fromHook: 1,
          },
        }),
      },
      {
        name: 'label must be string or undefined',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_label',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'label',
          payload: {
            targetId: 'entry_root',
            label: 7,
          },
        }),
      },
      {
        name: 'session_info name must be string',
        row: seedEntryRow({
          sessionId: 'session_bad_payload',
          entryId: 'entry_bad_session_name',
          parentEntryId: null,
          timestamp: '2026-04-19T00:00:00.000Z',
          type: 'session_info',
          payload: {
            name: false,
          },
        }),
      },
    ];

    for (const invalidCase of invalidCases) {
      const { provider, sessionRepo, entryRepo } = createFixture();
      await sessionRepo.save(seedSessionRow({
        sessionId: 'session_bad_payload',
        provider: 'mysql',
        rootEntryId: invalidCase.row.entryId,
        leafEntryId: invalidCase.row.entryId,
      }));
      await entryRepo.save(invalidCase.row);

      await expect(provider.listEntries('session_bad_payload')).rejects.toBeInstanceOf(SessionTreeProviderError);
    }
  });

  it('rejects non-JSON-serializable plain-data boundaries for mysql metadata and payloads', async () => {
    const { provider } = createFixture();
    const session = await provider.createSession({
      sessionId: 'session_json_boundaries',
    });

    await expect(provider.createSession({
      sessionId: 'session_bad_metadata_fn',
      metadata: {
        bad: (() => 'x') as unknown as never,
      },
    })).rejects.toBeInstanceOf(SessionTreeProviderError);

    await expect(provider.updateSession(session.sessionId, {
      metadata: {
        bad: Symbol('x') as unknown as never,
      },
    })).rejects.toBeInstanceOf(SessionTreeProviderError);

    await expect(provider.appendEntry(session.sessionId, {
      id: 'entry_bad_metadata_bigint',
      parentId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'message',
      message: {
        role: 'user',
        content: 'hello',
      },
      metadata: {
        bad: BigInt(1) as unknown as never,
      },
    })).rejects.toBeInstanceOf(SessionTreeProviderError);

    await expect(provider.appendEntry(session.sessionId, {
      id: 'entry_bad_payload_non_plain',
      parentId: null,
      timestamp: '2026-04-19T00:00:00.000Z',
      type: 'custom',
      customType: 'artifact',
      data: {
        nested: new NonPlainRecord('x') as unknown as Record<string, unknown>,
      },
    })).rejects.toBeInstanceOf(SessionTreeProviderError);
  });

  it('getSession returns null and require-session APIs throw for missing sessions', async () => {
    const { provider } = createFixture();

    expect(await provider.getSession('missing')).toBeNull();
    await expect(provider.listEntries('missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.getActivePath('missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.getSnapshot('missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.appendEntry('missing', {
      parentId: null,
      type: 'message',
      message: {
        role: 'user',
        content: 'root',
      },
    })).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.updateSession('missing', {
      title: 'missing',
    })).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.updateEntry('missing', 'entry_missing', {
      metadata: {
        updated: true,
      },
    })).rejects.toBeInstanceOf(SessionTreeProviderError);
    await expect(provider.switchLeaf('missing', null)).rejects.toBeInstanceOf(SessionTreeProviderError);
  });
});
