import { generateToken, hashToken } from '@mcpctl/shared';
import type { McpToken } from '@prisma/client';
import type { IMcpTokenRepository, McpTokenWithRelations, McpTokenFilter } from '../repositories/interfaces.js';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import { CreateMcpTokenSchema } from '../validation/mcp-token.schema.js';
import { isResourceBinding, type RbacRoleBinding, type RbacSubject } from '../validation/rbac-definition.schema.js';
import type { RbacService, Permission } from './rbac.service.js';
import { ROLE_ACTIONS_FOR_CEILING } from './rbac.service.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';

/** Thrown when the requesting user tries to mint a token with bindings they cannot grant themselves. */
export class PermissionCeilingError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'PermissionCeilingError';
  }
}

export interface CreateMcpTokenResult {
  /** The database row (with project/owner relations). */
  mcpToken: McpTokenWithRelations;
  /** The raw bearer token — shown exactly once. */
  raw: string;
}

export interface IntrospectResult {
  ok: boolean;
  tokenId?: string;
  tokenName?: string;
  tokenSha?: string;
  projectId?: string;
  projectName?: string;
  ownerId?: string;
  expired?: boolean;
  revoked?: boolean;
}

export class McpTokenService {
  constructor(
    private readonly tokenRepo: IMcpTokenRepository,
    private readonly projectRepo: IProjectRepository,
    private readonly rbacRepo: IRbacDefinitionRepository,
    private readonly rbacService: RbacService,
  ) {}

  async list(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]> {
    return this.tokenRepo.findAll(filter);
  }

  async getById(id: string): Promise<McpTokenWithRelations> {
    const row = await this.tokenRepo.findById(id);
    if (row === null) throw new NotFoundError(`McpToken not found: ${id}`);
    return row;
  }

  /** Hash + lookup a raw bearer. Returns the row if valid and active; null if missing, revoked, or expired. */
  async introspectRaw(raw: string): Promise<IntrospectResult> {
    const hash = hashToken(raw);
    const row = await this.tokenRepo.findByHash(hash);
    if (row === null) return { ok: false };

    const now = new Date();
    const revoked = row.revokedAt !== null;
    const expired = row.expiresAt !== null && row.expiresAt < now;

    if (revoked || expired) {
      return {
        ok: false,
        tokenId: row.id,
        tokenName: row.name,
        tokenSha: row.tokenHash,
        revoked,
        expired,
      };
    }

    // Best-effort last-used tracking (don't block on this).
    this.tokenRepo.touchLastUsed(row.id).catch(() => { /* ignore */ });

    return {
      ok: true,
      tokenId: row.id,
      tokenName: row.name,
      tokenSha: row.tokenHash,
      projectId: row.projectId,
      projectName: row.project.name,
      ownerId: row.ownerId,
      expired: false,
      revoked: false,
    };
  }

  async create(creatorUserId: string, input: unknown): Promise<CreateMcpTokenResult> {
    const data = CreateMcpTokenSchema.parse(input);

    const project = await this.projectRepo.findById(data.projectId);
    if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);

    const existing = await this.tokenRepo.findByNameAndProject(data.name, data.projectId);
    if (existing !== null && existing.revokedAt === null) {
      throw new ConflictError(`McpToken already exists: ${data.name} in project ${project.name}`);
    }

    // Resolve the effective bindings:
    //   base = rbacMode === 'clone' ? snapshot(creator) : []
    //   effective = base + explicit bindings
    const basePerms = data.rbacMode === 'clone'
      ? await this.rbacService.getPermissions(creatorUserId)
      : [];
    const baseBindings = basePerms.map(permissionToBinding);
    const effectiveBindings: RbacRoleBinding[] = [...baseBindings, ...data.bindings];

    // Creator ceiling: every effective binding must be within what creator can do.
    // Cloned bindings are trivially satisfied; explicit ones may not be.
    for (const binding of data.bindings) {
      const violation = await this.checkCeiling(creatorUserId, binding);
      if (violation !== null) throw new PermissionCeilingError(violation);
    }

    // Generate the token
    const { raw, hash, prefix } = generateToken();

    // Normalize expiresAt
    let expiresAt: Date | null = null;
    if (data.expiresAt !== undefined && data.expiresAt !== null) {
      expiresAt = typeof data.expiresAt === 'string' ? new Date(data.expiresAt) : data.expiresAt;
    }

    const createArgs: {
      name: string;
      projectId: string;
      ownerId: string;
      tokenHash: string;
      tokenPrefix: string;
      description?: string;
      expiresAt: Date | null;
    } = {
      name: data.name,
      projectId: data.projectId,
      ownerId: creatorUserId,
      tokenHash: hash,
      tokenPrefix: prefix,
      expiresAt,
    };
    if (data.description !== undefined) createArgs.description = data.description;
    const row = await this.tokenRepo.create(createArgs);

    // If the token has bindings, auto-create an RbacDefinition so the token is a real RBAC principal.
    if (effectiveBindings.length > 0) {
      const subject: RbacSubject = { kind: 'McpToken', name: hash };
      await this.rbacRepo.create({
        name: rbacDefNameFor(row),
        subjects: [subject],
        roleBindings: effectiveBindings,
      });
    }

    return { mcpToken: row, raw };
  }

  async revoke(id: string): Promise<McpTokenWithRelations> {
    const existing = await this.getById(id);
    const row = await this.tokenRepo.revoke(id);
    // Remove the RBAC definition so the token's bindings stop resolving immediately.
    await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ });
    return row;
  }

  async delete(id: string): Promise<void> {
    const existing = await this.getById(id);
    await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ });
    await this.tokenRepo.delete(id);
  }

  private async deleteRbacDefinitionFor(row: McpToken): Promise<void> {
    const name = rbacDefNameFor(row);
    const existing = await this.rbacRepo.findByName(name);
    if (existing === null) return;
    await this.rbacRepo.delete(existing.id);
  }

  /**
   * For a single requested binding, return null if the creator can grant it,
   * or a human-readable reason string if they cannot.
   */
  private async checkCeiling(creatorUserId: string, binding: RbacRoleBinding): Promise<string | null> {
    if (isResourceBinding(binding)) {
      const grantedActions = ROLE_ACTIONS_FOR_CEILING[binding.role] ?? [];
      for (const action of grantedActions) {
        const ok = await this.rbacService.canAccess(
          creatorUserId,
          action,
          binding.resource,
          binding.name,
        );
        if (!ok) {
          return `Ceiling violation: you do not have permission '${action}' on ${binding.resource}${binding.name !== undefined ? `/${binding.name}` : ''}`;
        }
      }
      return null;
    }
    // Operation binding
    const ok = await this.rbacService.canRunOperation(creatorUserId, binding.action);
    if (!ok) return `Ceiling violation: you cannot run operation '${binding.action}'`;
    return null;
  }
}

function permissionToBinding(p: Permission): RbacRoleBinding {
  if ('resource' in p) {
    return p.name !== undefined
      ? { role: p.role as RbacRoleBinding extends { role: infer R } ? R : never, resource: p.resource, name: p.name } as RbacRoleBinding
      : { role: p.role, resource: p.resource } as RbacRoleBinding;
  }
  return { role: 'run', action: p.action };
}

function rbacDefNameFor(row: { id: string }): string {
  // Must match the regex in CreateRbacDefinitionSchema (lowercase alphanumeric with hyphens).
  return `mcptoken-${row.id.toLowerCase()}`;
}
