import { describe, it, expect, vi } from 'vitest';
import { PersonalityService } from '../src/services/personality.service.js';
import type { IPersonalityRepository } from '../src/repositories/personality.repository.js';
import type { IAgentRepository } from '../src/repositories/agent.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { Agent, Personality, PersonalityPrompt, Prompt } from '@prisma/client';

function makeAgent(overrides: Partial<Agent> = {}): Agent {
  return {
    id: 'agent-1',
    name: 'reviewer',
    description: '',
    systemPrompt: '',
    llmId: 'llm-1',
    projectId: null,
    defaultPersonalityId: null,
    proxyModelName: null,
    defaultParams: {} as Agent['defaultParams'],
    extras: {} as Agent['extras'],
    ownerId: 'owner-1',
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function makePersonality(overrides: Partial<Personality> = {}): Personality {
  return {
    id: `pers-${Math.random().toString(36).slice(2, 8)}`,
    name: 'grumpy',
    description: '',
    agentId: 'agent-1',
    priority: 5,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
  return {
    id: `prompt-${Math.random().toString(36).slice(2, 8)}`,
    name: 'p',
    content: 'c',
    projectId: null,
    agentId: null,
    priority: 5,
    summary: null,
    chapters: null,
    linkTarget: null,
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function mockPersonalityRepo(initial: Personality[] = []): IPersonalityRepository {
  const rows = new Map<string, Personality>(initial.map((r) => [r.id, r]));
  const bindings = new Map<string, PersonalityPrompt>();
  const key = (pid: string, prid: string): string => `${pid}::${prid}`;
  return {
    findAll: vi.fn(async () => [...rows.values()]),
    findByAgent: vi.fn(async (agentId: string) =>
      [...rows.values()].filter((r) => r.agentId === agentId)),
    findById: vi.fn(async (id: string) => rows.get(id) ?? null),
    findByNameAndAgent: vi.fn(async (name: string, agentId: string) => {
      for (const r of rows.values()) {
        if (r.name === name && r.agentId === agentId) return r;
      }
      return null;
    }),
    create: vi.fn(async (data) => {
      const row = makePersonality({
        id: `pers-${String(rows.size + 1)}`,
        name: data.name,
        description: data.description ?? '',
        agentId: data.agentId,
        priority: data.priority ?? 5,
      });
      rows.set(row.id, row);
      return row;
    }),
    update: vi.fn(async (id, data) => {
      const existing = rows.get(id);
      if (!existing) throw new Error('not found');
      const next: Personality = {
        ...existing,
        ...(data.description !== undefined ? { description: data.description } : {}),
        ...(data.priority !== undefined ? { priority: data.priority } : {}),
      };
      rows.set(id, next);
      return next;
    }),
    delete: vi.fn(async (id: string) => {
      rows.delete(id);
      // Cascade-emulate: drop bindings whose personality is gone.
      for (const k of [...bindings.keys()]) {
        if (k.startsWith(`${id}::`)) bindings.delete(k);
      }
    }),
    listPrompts: vi.fn(async (personalityId: string) =>
      [...bindings.values()]
        .filter((b) => b.personalityId === personalityId)
        .map((b) => ({
          ...b,
          prompt: makePrompt({ id: b.promptId, name: `prompt-${b.promptId}` }),
        }))),
    attachPrompt: vi.fn(async (personalityId: string, promptId: string, priority?: number) => {
      const binding: PersonalityPrompt = {
        id: `bind-${bindings.size + 1}`,
        personalityId,
        promptId,
        priority: priority ?? 5,
        createdAt: new Date(),
      };
      bindings.set(key(personalityId, promptId), binding);
      return binding;
    }),
    detachPrompt: vi.fn(async (personalityId: string, promptId: string) => {
      bindings.delete(key(personalityId, promptId));
    }),
    findBinding: vi.fn(async (personalityId: string, promptId: string) =>
      bindings.get(key(personalityId, promptId)) ?? null),
  };
}

function mockAgentRepo(agents: Agent[] = []): IAgentRepository {
  const rows = new Map<string, Agent>(agents.map((r) => [r.id, r]));
  return {
    findAll: vi.fn(async () => [...rows.values()]),
    findById: vi.fn(async (id: string) => rows.get(id) ?? null),
    findByName: vi.fn(async (name: string) => {
      for (const r of rows.values()) if (r.name === name) return r;
      return null;
    }),
    findByProjectId: vi.fn(async (projectId: string) =>
      [...rows.values()].filter((r) => r.projectId === projectId)),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
  };
}

function mockPromptRepo(prompts: Prompt[] = []): IPromptRepository {
  const rows = new Map<string, Prompt>(prompts.map((p) => [p.id, p]));
  return {
    findAll: vi.fn(async () => [...rows.values()]),
    findGlobal: vi.fn(async () =>
      [...rows.values()].filter((p) => p.projectId === null && p.agentId === null)),
    findByAgent: vi.fn(async (agentId: string) =>
      [...rows.values()].filter((p) => p.agentId === agentId)),
    findById: vi.fn(async (id: string) => rows.get(id) ?? null),
    findByNameAndProject: vi.fn(),
    findByNameAndAgent: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
  };
}

describe('PersonalityService', () => {
  it('creates a personality bound to an agent', async () => {
    const agent = makeAgent();
    const repo = mockPersonalityRepo();
    const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());

    const p = await service.create('reviewer', { name: 'grumpy', priority: 7 });

    expect(p.name).toBe('grumpy');
    expect(p.agentName).toBe('reviewer');
    expect(p.priority).toBe(7);
    expect(p.promptCount).toBe(0);
  });

  it('rejects create when the agent does not exist', async () => {
    const service = new PersonalityService(
      mockPersonalityRepo(),
      mockAgentRepo([]),
      mockPromptRepo(),
    );
    await expect(service.create('ghost', { name: 'x' })).rejects.toThrow(/Agent not found/);
  });

  it('rejects duplicate (name, agent) personalities', async () => {
    const agent = makeAgent();
    const repo = mockPersonalityRepo([
      makePersonality({ id: 'pers-existing', name: 'grumpy', agentId: 'agent-1' }),
    ]);
    const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());

    await expect(service.create('reviewer', { name: 'grumpy' })).rejects.toThrow(/already exists/);
  });

  it('lists personalities for an agent', async () => {
    const agent = makeAgent();
    const repo = mockPersonalityRepo([
      makePersonality({ id: 'p1', name: 'a', agentId: 'agent-1' }),
      makePersonality({ id: 'p2', name: 'b', agentId: 'agent-1' }),
      makePersonality({ id: 'p3', name: 'c', agentId: 'other-agent' }),
    ]);
    const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());

    const list = await service.listForAgent('reviewer');
    expect(list.map((p) => p.name).sort()).toEqual(['a', 'b']);
  });

  it('updates description + priority', async () => {
    const agent = makeAgent();
    const personality = makePersonality({ id: 'pers-up', description: 'old', priority: 3 });
    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo());

    const updated = await service.update('pers-up', { description: 'new', priority: 9 });
    expect(updated.description).toBe('new');
    expect(updated.priority).toBe(9);
  });

  it('attaches an agent-direct prompt', async () => {
    const agent = makeAgent();
    const personality = makePersonality({ id: 'pers-1', agentId: agent.id });
    const prompt = makePrompt({ id: 'pr-1', agentId: agent.id });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([prompt]),
    );

    const binding = await service.attachPrompt('pers-1', { promptId: 'pr-1', priority: 8 });
    expect(binding.priority).toBe(8);
  });

  it('attaches a prompt from the agent\'s project', async () => {
    const agent = makeAgent({ projectId: 'proj-1' });
    const personality = makePersonality({ id: 'pers-2', agentId: agent.id });
    const prompt = makePrompt({ id: 'pr-proj', projectId: 'proj-1' });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([prompt]),
    );

    const binding = await service.attachPrompt('pers-2', { promptId: 'pr-proj' });
    expect(binding.promptId).toBe('pr-proj');
  });

  it('attaches a global prompt (projectId=null, agentId=null)', async () => {
    const agent = makeAgent();
    const personality = makePersonality({ id: 'pers-3', agentId: agent.id });
    const prompt = makePrompt({ id: 'pr-global' });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([prompt]),
    );

    const binding = await service.attachPrompt('pers-3', { promptId: 'pr-global' });
    expect(binding.promptId).toBe('pr-global');
  });

  it('rejects attaching a prompt that belongs to a different agent', async () => {
    const agent = makeAgent({ id: 'agent-1' });
    const personality = makePersonality({ id: 'pers-4', agentId: 'agent-1' });
    const foreign = makePrompt({ id: 'pr-foreign', agentId: 'agent-other' });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([foreign]),
    );

    await expect(
      service.attachPrompt('pers-4', { promptId: 'pr-foreign' }),
    ).rejects.toThrow(/different agent/);
  });

  it('rejects attaching a prompt from a foreign project', async () => {
    const agent = makeAgent({ projectId: 'proj-1' });
    const personality = makePersonality({ id: 'pers-5', agentId: agent.id });
    const foreign = makePrompt({ id: 'pr-other-proj', projectId: 'proj-other' });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([foreign]),
    );

    await expect(
      service.attachPrompt('pers-5', { promptId: 'pr-other-proj' }),
    ).rejects.toThrow(/different project/);
  });

  it('rejects double-attach of the same prompt', async () => {
    const agent = makeAgent();
    const personality = makePersonality({ id: 'pers-dup', agentId: agent.id });
    const prompt = makePrompt({ id: 'pr-dup', agentId: agent.id });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([prompt]),
    );

    await service.attachPrompt('pers-dup', { promptId: 'pr-dup' });
    await expect(
      service.attachPrompt('pers-dup', { promptId: 'pr-dup' }),
    ).rejects.toThrow(/already bound/);
  });

  it('detaches a prompt and rejects detaching one that was never bound', async () => {
    const agent = makeAgent();
    const personality = makePersonality({ id: 'pers-det', agentId: agent.id });
    const prompt = makePrompt({ id: 'pr-det', agentId: agent.id });

    const repo = mockPersonalityRepo([personality]);
    const service = new PersonalityService(
      repo,
      mockAgentRepo([agent]),
      mockPromptRepo([prompt]),
    );

    await service.attachPrompt('pers-det', { promptId: 'pr-det' });
    await service.detachPrompt('pers-det', 'pr-det');

    await expect(service.detachPrompt('pers-det', 'pr-det')).rejects.toThrow(/not bound/);
  });
});
