/**
 * AgentService — CRUD over `Agent` rows.
 *
 * Agents pin to one Llm (FK Restrict, so an Llm in active use can't be
 * deleted from under them) and optionally attach to a Project (FK SetNull —
 * agents survive project deletion). The service translates name-based
 * references (`{ llm: { name } }`, `{ project: { name } }`) to the FK ids
 * on write, and back to names on read so callers always work with stable
 * identifiers.
 */
import type { Agent } from '@prisma/client';
import type { IAgentRepository } from '../repositories/agent.repository.js';
import type { IPersonalityRepository } from '../repositories/personality.repository.js';
import type { LlmService } from './llm.service.js';
import type { ProjectService } from './project.service.js';
import {
  CreateAgentSchema,
  UpdateAgentSchema,
  type AgentChatParams,
  type CreateAgentInput,
} from '../validation/agent.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';

/**
 * v7: visibility scope for the current request, mirrors LlmService.Viewer.
 * Same semantics — null = no filter; wildcard = full grant; else
 * filter to public-or-owned-or-name-scoped.
 */
export interface AgentViewer {
  userId: string;
  wildcard: boolean;
  allowedNames: Set<string>;
}

export function isAgentVisibleTo(
  row: { name: string; ownerId: string; visibility: string },
  viewer: AgentViewer | null,
): boolean {
  if (viewer === null) return true;
  if (viewer.wildcard) return true;
  if (row.visibility !== 'private') return true;
  if (row.ownerId === viewer.userId) return true;
  if (viewer.allowedNames.has(row.name)) return true;
  return false;
}

/** Shape returned by the API layer — embeds llm + project metadata. */
export interface AgentView {
  id: string;
  name: string;
  description: string;
  systemPrompt: string;
  llm: { id: string; name: string };
  project: { id: string; name: string } | null;
  defaultPersonality: { id: string; name: string } | null;
  proxyModelName: string | null;
  defaultParams: AgentChatParams;
  extras: Record<string, unknown>;
  // Virtual-agent lifecycle (defaults make public agents look like "active").
  kind: 'public' | 'virtual';
  status: 'active' | 'inactive' | 'hibernating';
  lastHeartbeatAt: Date | null;
  inactiveSince: Date | null;
  ownerId: string;
  /** v7: per-user RBAC scoping. mcplocal-published virtuals default to 'private'. */
  visibility: 'public' | 'private';
  version: number;
  createdAt: Date;
  updatedAt: Date;
}

/** Input shape mcplocal sends per virtual agent on register. */
export interface VirtualAgentInput {
  name: string;
  llmName: string;
  description?: string;
  systemPrompt?: string;
  project?: string;
  defaultParams?: Record<string, unknown>;
  extras?: Record<string, unknown>;
  /**
   * v7: per-user RBAC scoping. When omitted, virtual agents default to
   * 'private' on register — same shape as virtual Llms. The publisher
   * can override per-agent in mcplocal config.
   */
  visibility?: 'public' | 'private';
}

export class AgentService {
  constructor(
    private readonly repo: IAgentRepository,
    private readonly llms: LlmService,
    private readonly projects: ProjectService,
    private readonly personalities?: IPersonalityRepository,
  ) {}

  async list(viewer: AgentViewer | null = null): Promise<AgentView[]> {
    const rows = await this.repo.findAll();
    const visible = rows.filter((r) => isAgentVisibleTo(r, viewer));
    return Promise.all(visible.map((r) => this.toView(r)));
  }

  async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise<AgentView[]> {
    const project = await this.projects.resolveAndGet(projectName);
    const rows = await this.repo.findByProjectId(project.id);
    const visible = rows.filter((r) => isAgentVisibleTo(r, viewer));
    return Promise.all(visible.map((r) => this.toView(r)));
  }

  async getById(id: string, viewer: AgentViewer | null = null): Promise<AgentView> {
    const row = await this.repo.findById(id);
    if (row === null) throw new NotFoundError(`Agent not found: ${id}`);
    if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${id}`);
    return this.toView(row);
  }

  async getByName(name: string, viewer: AgentViewer | null = null): Promise<AgentView> {
    const row = await this.repo.findByName(name);
    if (row === null) throw new NotFoundError(`Agent not found: ${name}`);
    if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${name}`);
    return this.toView(row);
  }

  async create(input: unknown, ownerId: string): Promise<AgentView> {
    const data = CreateAgentSchema.parse(input);
    const existing = await this.repo.findByName(data.name);
    if (existing !== null) throw new ConflictError(`Agent already exists: ${data.name}`);

    const llm = 'name' in data.llm ? await this.llms.getByName(data.llm.name) : await this.llms.getById(data.llm.id);
    const projectId = data.project !== undefined
      ? (await this.projects.resolveAndGet(data.project.name)).id
      : null;

    const row = await this.repo.create({
      name: data.name,
      description: data.description,
      systemPrompt: data.systemPrompt,
      llmId: llm.id,
      projectId,
      proxyModelName: data.proxyModelName ?? null,
      defaultParams: data.defaultParams as Record<string, unknown>,
      extras: data.extras,
      ownerId,
    });
    return this.toView(row);
  }

  async update(id: string, input: unknown): Promise<AgentView> {
    const data = UpdateAgentSchema.parse(input);
    await this.getById(id);

    const updateFields: Parameters<IAgentRepository['update']>[1] = {};
    if (data.description !== undefined) updateFields.description = data.description;
    if (data.systemPrompt !== undefined) updateFields.systemPrompt = data.systemPrompt;
    if (data.llm !== undefined) {
      const llm = 'name' in data.llm ? await this.llms.getByName(data.llm.name) : await this.llms.getById(data.llm.id);
      updateFields.llmId = llm.id;
    }
    if (data.project !== undefined) {
      updateFields.projectId = data.project === null
        ? null
        : (await this.projects.resolveAndGet(data.project.name)).id;
    }
    if (data.defaultPersonality !== undefined) {
      if (data.defaultPersonality === null) {
        updateFields.defaultPersonalityId = null;
      } else {
        if (this.personalities === undefined) {
          throw new Error('PersonalityRepository must be wired into AgentService to set defaultPersonality');
        }
        const agent = await this.repo.findById(id);
        if (agent === null) throw new NotFoundError(`Agent not found: ${id}`);
        const personality = await this.personalities.findByNameAndAgent(
          data.defaultPersonality.name,
          agent.id,
        );
        if (personality === null) {
          throw new NotFoundError(`Personality not found on agent ${agent.name}: ${data.defaultPersonality.name}`);
        }
        updateFields.defaultPersonalityId = personality.id;
      }
    }
    if (data.proxyModelName !== undefined) updateFields.proxyModelName = data.proxyModelName;
    if (data.defaultParams !== undefined) updateFields.defaultParams = data.defaultParams as Record<string, unknown>;
    if (data.extras !== undefined) updateFields.extras = data.extras;

    const row = await this.repo.update(id, updateFields);
    return this.toView(row);
  }

  async delete(id: string): Promise<void> {
    await this.getById(id);
    await this.repo.delete(id);
  }

  // ── Backup/restore helpers ──

  async upsertByName(input: CreateAgentInput, ownerId: string): Promise<AgentView> {
    const existing = await this.repo.findByName(input.name);
    if (existing !== null) {
      return this.update(existing.id, input);
    }
    return this.create(input, ownerId);
  }

  async deleteByName(name: string): Promise<void> {
    const row = await this.repo.findByName(name);
    if (row === null) return;
    await this.delete(row.id);
  }

  private async toView(row: Agent): Promise<AgentView> {
    const llm = await this.llms.getById(row.llmId);
    const project = row.projectId !== null
      ? await this.projects.getById(row.projectId).catch(() => null)
      : null;
    let defaultPersonality: { id: string; name: string } | null = null;
    if (row.defaultPersonalityId !== null && this.personalities !== undefined) {
      const p = await this.personalities.findById(row.defaultPersonalityId);
      if (p !== null) defaultPersonality = { id: p.id, name: p.name };
    }
    return {
      id: row.id,
      name: row.name,
      description: row.description,
      systemPrompt: row.systemPrompt,
      llm: { id: llm.id, name: llm.name },
      project: project !== null ? { id: project.id, name: project.name } : null,
      defaultPersonality,
      proxyModelName: row.proxyModelName,
      defaultParams: row.defaultParams as AgentChatParams,
      extras: row.extras as Record<string, unknown>,
      kind: row.kind,
      status: row.status,
      lastHeartbeatAt: row.lastHeartbeatAt,
      inactiveSince: row.inactiveSince,
      ownerId: row.ownerId,
      visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private',
      version: row.version,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    };
  }

  // ── Virtual-agent lifecycle (v3) ──

  /**
   * Sticky upsert of virtual agents owned by a publishing mcplocal session.
   * Mirrors VirtualLlmService.register's semantics:
   *   - New agents → insert with kind=virtual / status=active.
   *   - Existing virtual agents owned by the same session → update + reactivate.
   *   - Existing virtual agents owned by a different session, but currently
   *     inactive → adopt (sticky reconnect after a session lapse).
   *   - Existing public agents OR foreign-active virtuals → 409 Conflict.
   *   - Pinned LLM must already exist (publisher posts Llms first in the same
   *     register payload).
   */
  async registerVirtualAgents(
    sessionId: string,
    inputs: VirtualAgentInput[],
    ownerId: string,
  ): Promise<AgentView[]> {
    const now = new Date();
    const out: AgentView[] = [];
    for (const a of inputs) {
      const llm = await this.llms.getByName(a.llmName);
      // v6: auto-create the project if it doesn't exist. Publishers
      // commonly want their virtual agents pinned to a team-scoped
      // project; pre-v6 they had to ask an admin to create the
      // project first. ensureByName is idempotent — the second
      // publisher landing on the same name reuses the existing row.
      // Owner is the publishing user (first writer wins).
      const projectId = a.project !== undefined
        ? (await this.projects.ensureByName(
            a.project,
            ownerId,
            { auditNote: `Auto-created by virtual-agent registrar for '${a.name}'` },
          )).id
        : null;
      const existing = await this.repo.findByName(a.name);
      if (existing !== null) {
        if (existing.kind === 'public') {
          throw Object.assign(
            new Error(`Cannot publish over public Agent: ${a.name}`),
            { statusCode: 409 },
          );
        }
        if (existing.providerSessionId !== sessionId && existing.status === 'active') {
          throw Object.assign(
            new Error(`Virtual Agent '${a.name}' is already active under a different session`),
            { statusCode: 409 },
          );
        }
        const updated = await this.repo.update(existing.id, {
          ...(a.description !== undefined ? { description: a.description } : {}),
          ...(a.systemPrompt !== undefined ? { systemPrompt: a.systemPrompt } : {}),
          llmId: llm.id,
          projectId,
          ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
          ...(a.extras !== undefined ? { extras: a.extras } : {}),
          // v7: only update visibility on sticky reconnect when the
          // publisher explicitly sent it — operators may have flipped
          // a virtual agent to public manually via `mcpctl edit agent`.
          ...(a.visibility !== undefined ? { visibility: a.visibility } : {}),
          kind: 'virtual',
          providerSessionId: sessionId,
          status: 'active',
          lastHeartbeatAt: now,
          inactiveSince: null,
        });
        out.push(await this.toView(updated));
        continue;
      }
      const created = await this.repo.create({
        name: a.name,
        ...(a.description !== undefined ? { description: a.description } : {}),
        ...(a.systemPrompt !== undefined ? { systemPrompt: a.systemPrompt } : {}),
        llmId: llm.id,
        projectId,
        ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
        ...(a.extras !== undefined ? { extras: a.extras } : {}),
        // v7: virtual agents default to private on first publish.
        visibility: a.visibility ?? 'private',
        kind: 'virtual',
        providerSessionId: sessionId,
        status: 'active',
        lastHeartbeatAt: now,
        ownerId,
      });
      out.push(await this.toView(created));
    }
    return out;
  }

  /**
   * Bumps lastHeartbeatAt on every virtual agent owned by the session.
   * Revives inactive rows. Called from VirtualLlmService.heartbeat so
   * one publisher heartbeat covers both Llms and Agents.
   */
  async heartbeatVirtualAgents(sessionId: string): Promise<void> {
    const owned = await this.repo.findBySessionId(sessionId);
    if (owned.length === 0) return;
    const now = new Date();
    for (const row of owned) {
      await this.repo.update(row.id, {
        lastHeartbeatAt: now,
        ...(row.status === 'inactive' ? { status: 'active', inactiveSince: null } : {}),
      });
    }
  }

  /** Flip every virtual agent owned by the session to inactive immediately. */
  async markVirtualAgentsInactiveBySession(sessionId: string): Promise<void> {
    const owned = await this.repo.findBySessionId(sessionId);
    const now = new Date();
    for (const row of owned) {
      if (row.status === 'active') {
        await this.repo.update(row.id, { status: 'inactive', inactiveSince: now });
      }
    }
  }

  /**
   * Cascade-delete virtual agents pinned to a virtual Llm. Called from
   * VirtualLlmService.gcSweep BEFORE deleting the inactive Llm row, since
   * Agent.llmId is `onDelete: Restrict` and would otherwise block the
   * Llm delete.
   */
  async deleteVirtualAgentsForLlm(llmId: string): Promise<number> {
    const pinned = await this.repo.findByLlmId(llmId);
    let deleted = 0;
    for (const row of pinned) {
      if (row.kind !== 'virtual') continue;
      await this.repo.delete(row.id);
      deleted += 1;
    }
    return deleted;
  }

  /**
   * GC sweep for virtual agents — same shape as VirtualLlmService.gcSweep:
   *   1. Heartbeat-stale active virtuals → inactive (90-s cutoff).
   *   2. 4-h-old inactive virtuals → delete.
   * Run BEFORE the LlmService GC sweep so any agent that would have
   * blocked an Llm delete via Restrict has already been cleared.
   */
  async gcSweepVirtualAgents(now: Date = new Date()): Promise<{ markedInactive: number; deleted: number }> {
    const HEARTBEAT_TIMEOUT_MS = 90_000;
    const INACTIVE_RETENTION_MS = 4 * 60 * 60 * 1000;
    let markedInactive = 0;
    let deleted = 0;

    const heartbeatCutoff = new Date(now.getTime() - HEARTBEAT_TIMEOUT_MS);
    const stale = await this.repo.findStaleVirtuals(heartbeatCutoff);
    for (const row of stale) {
      await this.repo.update(row.id, { status: 'inactive', inactiveSince: now });
      markedInactive += 1;
    }

    const deletionCutoff = new Date(now.getTime() - INACTIVE_RETENTION_MS);
    const expired = await this.repo.findExpiredInactives(deletionCutoff);
    for (const row of expired) {
      await this.repo.delete(row.id);
      deleted += 1;
    }
    return { markedInactive, deleted };
  }
}
