import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { assertSupportedRoleTools } from "./roleTools.js";
import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";

const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;

export interface AgentRoleRow {
  readonly id: string;
  readonly roleId: string;
  readonly label: string;
  readonly defaultModel: string | null;
  readonly systemPrompt: string | null;
  readonly tools: readonly string[] | null;
  readonly sortOrder: number;
  readonly isDefault: boolean;
  readonly disabledAt: string | null;
  readonly createdAt: string;
  readonly updatedAt: string;
  readonly skillNames: readonly string[];
}

export interface AgentSkillRow {
  readonly id: string;
  readonly name: string;
  readonly version: string;
  readonly description: string | null;
  readonly contentDigest: string;
  readonly disabledAt: string | null;
  readonly createdAt: string;
  readonly updatedAt: string;
  readonly boundRoleIds: readonly string[];
}

/**
 * Deep module for controlled host-console Agent configuration. It owns the
 * filesystem/DB ordering, Organization checks and role-skill composition so
 * callers never manipulate registry rows or content paths independently.
 */
export class OrganizationAgentConfiguration {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly skillStoreRoot: string | null,
  ) {}

  async listRoles(input: { readonly organizationId: string }): Promise<readonly AgentRoleRow[]> {
    await this.requireActiveOrganization(input.organizationId);
    const roles = await this.prisma.organizationAgentRole.findMany({
      where: { organizationId: input.organizationId, disabledAt: null },
      orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
      include: {
        skillBindings: {
          orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
          select: { skill: { select: { name: true, disabledAt: true } } },
        },
      },
    });
    return roles.map((role) => toRoleRow(role));
  }

  async listSkills(input: { readonly organizationId: string }): Promise<readonly AgentSkillRow[]> {
    await this.requireActiveOrganization(input.organizationId);
    const skills = await this.prisma.organizationAgentSkill.findMany({
      where: { organizationId: input.organizationId, disabledAt: null },
      orderBy: [{ name: "asc" }],
      include: {
        roleBindings: {
          where: { role: { disabledAt: null } },
          select: { role: { select: { roleId: true } } },
        },
      },
    });
    return skills.map((skill) => ({
      id: skill.id,
      name: skill.name,
      version: skill.version,
      description: skill.description,
      contentDigest: skill.contentDigest,
      disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(),
      createdAt: skill.createdAt.toISOString(),
      updatedAt: skill.updatedAt.toISOString(),
      boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
    }));
  }

  async installSkill(input: {
    readonly organizationId: string;
    readonly sourceDir: string;
    readonly version: string;
  }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
    const storeRoot = this.requireSkillStoreRoot();
    await this.requireActiveOrganization(input.organizationId);
    const version = nonEmpty(input.version, "skill version");
    const imported = await importSkillDirectory({
      sourceDir: input.sourceDir,
      storeRoot,
    });
    return this.commitInstalledSkill({
      organizationId: input.organizationId,
      imported,
      version,
      action: "agent_skill.installed",
    });
  }

  /**
   * Install or replace a skill from an in-memory file list (web upload path).
   * Same content-addressed storage, same session archival semantics as
   * `installSkill`; only the ingestion source differs.
   */
  async installSkillFromFiles(input: {
    readonly organizationId: string;
    readonly files: readonly SkillFileInput[];
    readonly version: string;
  }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
    const storeRoot = this.requireSkillStoreRoot();
    await this.requireActiveOrganization(input.organizationId);
    const version = nonEmpty(input.version, "skill version");
    const imported = await importSkillFromFiles({
      files: input.files,
      storeRoot,
    });
    return this.commitInstalledSkill({
      organizationId: input.organizationId,
      imported,
      version,
      action: "agent_skill.installed",
    });
  }

  /**
   * Read all files from the stored skill version identified by content digest.
   * Returns UTF-8 content for each file; the web editor uses this to populate
   * its file tree.
   */
  async readSkillFiles(input: {
    readonly organizationId: string;
    readonly contentDigest: string;
  }): Promise<readonly SkillFileOutput[]> {
    const storeRoot = this.requireSkillStoreRoot();
    await this.requireActiveOrganization(input.organizationId);
    const skill = await this.prisma.organizationAgentSkill.findFirst({
      where: {
        organizationId: input.organizationId,
        contentDigest: input.contentDigest,
      },
      select: { id: true, disabledAt: true },
    });
    if (skill === null) {
      throw new Error(`skill not found in organization for digest: ${input.contentDigest}`);
    }
    return readSkillFiles({
      storeRoot,
      contentDigest: input.contentDigest,
    });
  }

  /**
   * Disable a skill (soft-delete). Sets `disabledAt` and archives active
   * sessions of every role bound to this skill, since the execution surface
   * changes when a bound skill disappears.
   */
  async disableSkill(input: { readonly organizationId: string; readonly name: string }): Promise<void> {
    await this.requireActiveOrganization(input.organizationId);
    await this.prisma.$transaction(async (tx) => {
      const skill = await tx.organizationAgentSkill.findUnique({
        where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
        select: { id: true, disabledAt: true, roleBindings: { select: { role: { select: { roleId: true } } } } },
      });
      if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`);
      if (skill.disabledAt !== null) return;
      await tx.organizationAgentSkill.update({
        where: { id: skill.id },
        data: { disabledAt: new Date() },
      });
      await archiveRoleSessions(
        tx,
        input.organizationId,
        skill.roleBindings.map((binding) => binding.role.roleId),
      );
      await tx.auditEntry.create({
        data: {
          organizationId: input.organizationId,
          action: "agent_skill.disabled",
          metadata: { name: input.name },
        },
      });
    });
  }

  /**
   * Update only the `description` label of a skill. This is a label change,
   * not an execution-surface change — no session archival (ADR-0017).
   */
  async updateSkillDescription(input: {
    readonly organizationId: string;
    readonly name: string;
    readonly description: string;
  }): Promise<void> {
    await this.requireActiveOrganization(input.organizationId);
    const description = input.description.trim();
    await this.prisma.$transaction(async (tx) => {
      const skill = await tx.organizationAgentSkill.findUnique({
        where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
        select: { id: true, disabledAt: true },
      });
      if (skill === null || skill.disabledAt !== null) {
        throw new Error(`active skill not found in organization: ${input.name}`);
      }
      await tx.organizationAgentSkill.update({
        where: { id: skill.id },
        data: { description: description === "" ? null : description },
      });
      await tx.auditEntry.create({
        data: {
          organizationId: input.organizationId,
          action: "agent_skill.description_updated",
          metadata: { name: input.name, description: description === "" ? null : description },
        },
      });
    });
  }

  private requireSkillStoreRoot(): string {
    if (this.skillStoreRoot === null) {
      throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
    }
    return this.skillStoreRoot;
  }

  /**
   * Shared DB commit for an imported skill: upsert the skill record, archive
   * affected sessions if the content digest changed, and write an audit entry.
   */
  private async commitInstalledSkill(input: {
    readonly organizationId: string;
    readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string };
    readonly version: string;
    readonly action: string;
  }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
    return this.prisma.$transaction(async (tx) => {
      const previous = await tx.organizationAgentSkill.findUnique({
        where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } },
        select: { contentDigest: true },
      });
      const skill = await tx.organizationAgentSkill.upsert({
        where: {
          organizationId_name: {
            organizationId: input.organizationId,
            name: input.imported.name,
          },
        },
        create: {
          organizationId: input.organizationId,
          name: input.imported.name,
          version: input.version,
          description: input.imported.description ?? null,
          contentDigest: input.imported.contentDigest,
        },
        update: {
          version: input.version,
          description: input.imported.description ?? null,
          contentDigest: input.imported.contentDigest,
          disabledAt: null,
        },
        select: {
          id: true,
          name: true,
          contentDigest: true,
          roleBindings: { select: { role: { select: { roleId: true } } } },
        },
      });
      if (previous !== null && previous.contentDigest !== skill.contentDigest) {
        await archiveRoleSessions(
          tx,
          input.organizationId,
          skill.roleBindings.map((binding) => binding.role.roleId),
        );
      }
      await tx.auditEntry.create({
        data: {
          organizationId: input.organizationId,
          action: input.action,
          metadata: {
            name: skill.name,
            version: input.version,
            contentDigest: skill.contentDigest,
          },
        },
      });
      return { id: skill.id, name: skill.name, contentDigest: skill.contentDigest };
    });
  }

  async upsertRole(input: {
    readonly organizationId: string;
    readonly roleId: string;
    readonly label: string;
    readonly defaultModel?: string | null | undefined;
    readonly systemPrompt?: string | null | undefined;
    readonly tools?: readonly string[] | null | undefined;
    readonly sortOrder?: number | undefined;
    readonly isDefault?: boolean | undefined;
  }): Promise<AgentRoleRow> {
    await this.requireActiveOrganization(input.organizationId);
    if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
    const label = nonEmpty(input.label, "role label");
    if (input.tools !== undefined && input.tools !== null) assertSupportedRoleTools([...input.tools]);
    const sortOrder = input.sortOrder ?? 0;
    if (!Number.isSafeInteger(sortOrder)) throw new Error("role sortOrder must be an integer");
    const createTools = input.tools === undefined || input.tools === null
      ? Prisma.DbNull
      : [...input.tools];
    const updateTools = input.tools === undefined
      ? undefined
      : input.tools === null
        ? Prisma.DbNull
        : [...input.tools];
    return this.prisma.$transaction(async (tx) => {
      const previous = await tx.organizationAgentRole.findUnique({
        where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
        select: { defaultModel: true, systemPrompt: true, tools: true, isDefault: true },
      });
      const currentDefault = await tx.organizationAgentRole.findFirst({
        where: { organizationId: input.organizationId, isDefault: true, disabledAt: null },
        select: { id: true },
      });
      const effectiveIsDefault = input.isDefault ?? previous?.isDefault ?? (currentDefault === null);
      if (input.isDefault === false && previous?.isDefault === true) {
        throw new Error("cannot unset the active default role without selecting a replacement");
      }
      if (effectiveIsDefault) {
        await tx.organizationAgentRole.updateMany({
          where: { organizationId: input.organizationId, isDefault: true },
          data: { isDefault: false },
        });
      }
      const role = await tx.organizationAgentRole.upsert({
        where: {
          organizationId_roleId: {
            organizationId: input.organizationId,
            roleId: input.roleId,
          },
        },
        create: {
          organizationId: input.organizationId,
          roleId: input.roleId,
          label,
          defaultModel: normalizeOptionalText(input.defaultModel),
          systemPrompt: normalizeOptionalText(input.systemPrompt),
          tools: createTools,
          sortOrder,
          isDefault: effectiveIsDefault,
        },
        update: {
          label,
          ...(input.defaultModel !== undefined ? { defaultModel: normalizeOptionalText(input.defaultModel) } : {}),
          ...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
          ...(updateTools !== undefined ? { tools: updateTools } : {}),
          sortOrder,
          isDefault: effectiveIsDefault,
          disabledAt: null,
        },
        include: {
          skillBindings: {
            orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
            select: { skill: { select: { name: true, disabledAt: true } } },
          },
        },
      });
      const executionSurfaceChanged = previous !== null && (
        (input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
        (input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
        (input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools))
      );
      const activeDefaultCount = await tx.organizationAgentRole.count({
        where: { organizationId: input.organizationId, isDefault: true, disabledAt: null },
      });
      if (activeDefaultCount !== 1) {
        throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
      }
      if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
      await tx.auditEntry.create({
        data: {
          organizationId: input.organizationId,
          action: "agent_role.upserted",
          metadata: {
            roleId: input.roleId,
            label,
            defaultModel: input.defaultModel === undefined ? "unchanged" : normalizeOptionalText(input.defaultModel),
            systemPromptConfigured: input.systemPrompt === undefined
              ? "unchanged"
              : normalizeOptionalText(input.systemPrompt) !== null,
            tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
            sortOrder,
            isDefault: effectiveIsDefault,
            defaultExplicit: input.isDefault !== undefined,
          },
        },
      });
      return toRoleRow(role);
    });
  }

  async setRoleSkills(input: {
    readonly organizationId: string;
    readonly roleId: string;
    readonly skillNames: readonly string[];
  }): Promise<void> {
    const uniqueNames = new Set(input.skillNames);
    if (uniqueNames.size !== input.skillNames.length) throw new Error("role skill names must be unique");
    await this.prisma.$transaction(async (tx) => {
      const role = await tx.organizationAgentRole.findUnique({
        where: {
          organizationId_roleId: {
            organizationId: input.organizationId,
            roleId: input.roleId,
          },
        },
        select: { id: true, disabledAt: true },
      });
      if (role === null || role.disabledAt !== null) {
        throw new Error(`active role not found in organization: ${input.roleId}`);
      }
      const skills = await tx.organizationAgentSkill.findMany({
        where: {
          organizationId: input.organizationId,
          name: { in: [...input.skillNames] },
          disabledAt: null,
        },
        select: { id: true, name: true },
      });
      if (skills.length !== input.skillNames.length) {
        const found = new Set(skills.map((skill) => skill.name));
        const missing = input.skillNames.filter((name) => !found.has(name));
        throw new Error(`active skills not found in organization: ${missing.join(", ")}`);
      }
      const byName = new Map(skills.map((skill) => [skill.name, skill.id]));
      await tx.organizationAgentRoleSkill.deleteMany({
        where: { organizationId: input.organizationId, agentRoleId: role.id },
      });
      if (input.skillNames.length > 0) {
        await tx.organizationAgentRoleSkill.createMany({
          data: input.skillNames.map((name, index) => ({
            organizationId: input.organizationId,
            agentRoleId: role.id,
            agentSkillId: byName.get(name)!,
            sortOrder: index,
          })),
        });
      }
      await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
      await tx.auditEntry.create({
        data: {
          organizationId: input.organizationId,
          action: "agent_role.skills_set",
          metadata: { roleId: input.roleId, skillNames: [...input.skillNames] },
        },
      });
    });
  }

  private async requireActiveOrganization(organizationId: string): Promise<void> {
    const organization = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { status: true },
    });
    if (organization === null) throw new Error(`organization not found: ${organizationId}`);
    if (organization.status !== "ACTIVE") {
      throw new Error(`organization ${organizationId} is ${organization.status}`);
    }
  }
}

async function archiveRoleSessions(
  tx: Prisma.TransactionClient,
  organizationId: string,
  roleIds: readonly string[],
): Promise<void> {
  if (roleIds.length === 0) return;
  const sessions = await tx.agentSession.findMany({
    where: {
      roleId: { in: [...new Set(roleIds)] },
      project: { organizationId },
    },
    select: { id: true, archivedAt: true, metadata: true },
  });
  const archivedAt = new Date();
  for (const session of sessions) {
    const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
      ? session.metadata as Prisma.JsonObject
      : {};
    await tx.agentSession.update({
      where: { id: session.id },
      data: {
        ...(session.archivedAt === null ? { archivedAt } : {}),
        metadata: { ...metadata, userResumable: false },
      },
    });
  }
}

function nonEmpty(value: string, label: string): string {
  const normalized = value.trim();
  if (normalized === "") throw new Error(`${label} is required`);
  return normalized;
}

function normalizeOptionalText(value: string | null | undefined): string | null {
  if (value === undefined || value === null) return null;
  const normalized = value.trim();
  return normalized === "" ? null : normalized;
}

function parseTools(value: Prisma.JsonValue): readonly string[] | null {
  if (value === null) return null;
  if (!Array.isArray(value)) return null;
  return value.filter((t): t is string => typeof t === "string");
}

function toRoleRow(role: {
  readonly id: string;
  readonly roleId: string;
  readonly label: string;
  readonly defaultModel: string | null;
  readonly systemPrompt: string | null;
  readonly tools: Prisma.JsonValue;
  readonly sortOrder: number;
  readonly isDefault: boolean;
  readonly disabledAt: Date | null;
  readonly createdAt: Date;
  readonly updatedAt: Date;
  readonly skillBindings: ReadonlyArray<{
    readonly skill: { readonly name: string; readonly disabledAt: Date | null };
  }>;
}): AgentRoleRow {
  return {
    id: role.id,
    roleId: role.roleId,
    label: role.label,
    defaultModel: role.defaultModel,
    systemPrompt: role.systemPrompt,
    tools: parseTools(role.tools),
    sortOrder: role.sortOrder,
    isDefault: role.isDefault,
    disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(),
    createdAt: role.createdAt.toISOString(),
    updatedAt: role.updatedAt.toISOString(),
    skillNames: role.skillBindings
      .filter((binding) => binding.skill.disabledAt === null)
      .map((binding) => binding.skill.name),
  };
}
