/**
 * @beignet/core/entitlements
 *
 * Provider-neutral product access primitives for Beignet applications.
 */

type MaybePromise<T> = T | Promise<T>;

/**
 * Stable app-defined capability key, such as "issues.create".
 */
export type EntitlementKey = string;

/**
 * Principal whose product access is being checked.
 */
export type EntitlementSubject = {
  /**
   * App-defined subject type, commonly "tenant" or "account".
   */
  type: string;
  /**
   * Stable subject identifier.
   */
  id: string;
};

/**
 * Input for checking whether a subject has access to a capability.
 */
export type EntitlementCheckInput<
  TKey extends EntitlementKey = EntitlementKey,
> = {
  /**
   * Capability being checked.
   */
  entitlement: TKey;
  /**
   * Tenant, account, or other app-owned subject whose product access is checked.
   */
  subject: EntitlementSubject;
};

/**
 * An entitlement decision that allows the capability.
 */
export type EntitlementAllowedDecision = {
  allowed: true;
};

/**
 * An entitlement decision that denies the capability.
 */
export type EntitlementDeniedDecision = {
  allowed: false;
  reason?: string;
  code?: string;
  details?: unknown;
};

/**
 * Normalized entitlement decision.
 */
export type EntitlementDecision =
  | EntitlementAllowedDecision
  | EntitlementDeniedDecision;

/**
 * Value an entitlement resolver may return.
 */
export type EntitlementResolverResult = boolean | EntitlementDecision;

/**
 * Entitlement port method that produced a decision.
 */
export type EntitlementDecisionSource = "can" | "inspect" | "require";

/**
 * Optional context for entitlement checks.
 */
export type EntitlementCheckOptions<TContext = unknown> = {
  /**
   * Application context used for the check. Observers can use this to copy
   * request correlation fields without coupling the resolver to app context.
   */
  ctx?: TContext;
  /**
   * Source to report to observers. Port methods set this automatically.
   */
  source?: EntitlementDecisionSource;
};

/**
 * App-facing product access port.
 */
export type EntitlementsPort<
  TKey extends EntitlementKey = EntitlementKey,
  TContext = unknown,
> = {
  /**
   * Return only whether the capability is allowed.
   */
  can(
    input: EntitlementCheckInput<TKey>,
    options?: EntitlementCheckOptions<TContext>,
  ): Promise<boolean>;
  /**
   * Return the full allow/deny decision without throwing.
   */
  inspect(
    input: EntitlementCheckInput<TKey>,
    options?: EntitlementCheckOptions<TContext>,
  ): Promise<EntitlementDecision>;
};

/**
 * Function that decides whether an entitlement is granted.
 */
export type EntitlementResolver<TKey extends EntitlementKey = EntitlementKey> =
  (
    input: EntitlementCheckInput<TKey>,
  ) => MaybePromise<EntitlementResolverResult>;

/**
 * Best-effort entitlement decision observation emitted by an entitlements port.
 *
 * Observers are diagnostic only. They do not participate in entitlement
 * control flow and thrown/rejected observer errors are ignored.
 */
export type EntitlementDecisionObservation<
  TContext = unknown,
  TKey extends EntitlementKey = EntitlementKey,
> = {
  /**
   * Port helper that produced the decision.
   */
  source: EntitlementDecisionSource;
  /**
   * Application context passed by the caller, when available.
   */
  ctx?: TContext;
  /**
   * Capability being evaluated.
   */
  entitlement: TKey;
  /**
   * Subject whose product access is checked.
   */
  subject: EntitlementSubject;
  /**
   * Normalized decision, when resolver evaluation returned normally.
   */
  decision?: EntitlementDecision;
  /**
   * Error thrown by the resolver, when evaluation failed.
   */
  error?: unknown;
  /**
   * Resolver duration, excluding observer work.
   */
  durationMs: number;
  /**
   * Correlation fields copied from the context when present.
   */
  requestId?: string;
  traceId?: string;
  spanId?: string;
  parentSpanId?: string;
  traceparent?: string;
};

/**
 * Best-effort observer called after each entitlement decision or resolver
 * error.
 */
export type EntitlementDecisionObserver<
  TContext = unknown,
  TKey extends EntitlementKey = EntitlementKey,
> = (
  observation: EntitlementDecisionObservation<TContext, TKey>,
) => MaybePromise<void>;

/**
 * Options for `createEntitlements(...)`.
 */
export type CreateEntitlementsOptions<
  TKey extends EntitlementKey = EntitlementKey,
  TContext = unknown,
> = {
  /**
   * Resolver that maps app-owned product state to an entitlement decision.
   */
  inspect: EntitlementResolver<TKey>;
  /**
   * Optional best-effort observer for entitlement decisions.
   *
   * This hook is diagnostic only: it cannot change decisions or thrown errors.
   */
  onDecision?: EntitlementDecisionObserver<TContext, TKey>;
};

/**
 * Static grant map keyed by subject key.
 */
export type StaticEntitlementGrantMap<
  TKey extends EntitlementKey = EntitlementKey,
> = Record<string, readonly TKey[]>;

/**
 * Options for `createStaticEntitlements(...)`.
 */
export type CreateStaticEntitlementsOptions<
  TKey extends EntitlementKey = EntitlementKey,
  TContext = unknown,
> = {
  /**
   * Granted capabilities keyed by subject. The default key is
   * `${subject.type}:${subject.id}`.
   */
  grants: StaticEntitlementGrantMap<TKey>;
  /**
   * Optional subject key mapper.
   */
  subjectKey?: (subject: EntitlementSubject) => string;
  /**
   * Default denial decision fields.
   */
  denied?: Omit<EntitlementDeniedDecision, "allowed">;
  /**
   * Optional best-effort observer for entitlement decisions.
   */
  onDecision?: EntitlementDecisionObserver<TContext, TKey>;
};

/**
 * Options accepted by `requireEntitlement(...)`.
 */
export type RequireEntitlementOptions<
  TKey extends EntitlementKey = EntitlementKey,
> = {
  /**
   * Create the error to throw instead of the framework default.
   */
  error?: (
    decision: EntitlementDeniedDecision,
    input: EntitlementCheckInput<TKey>,
  ) => unknown;
};

/**
 * Context shape consumed by `requireEntitlement(...)`.
 */
export type EntitlementsContext<TKey extends EntitlementKey = EntitlementKey> =
  {
    ports: {
      entitlements: EntitlementsPort<TKey>;
    };
  };

/**
 * Error thrown by `requireEntitlement(...)` when product access is denied.
 */
export class EntitlementRequiredError extends Error {
  readonly code: string;
  readonly status = 403;
  readonly details?: unknown;
  readonly entitlement: string;
  readonly subject: EntitlementSubject;

  constructor(
    input: EntitlementCheckInput,
    decision: EntitlementDeniedDecision = denyEntitlement(),
  ) {
    super(
      decision.reason ??
        `Entitlement "${input.entitlement}" is required for this action.`,
    );
    this.name = "EntitlementRequiredError";
    this.code = decision.code ?? "ENTITLEMENT_REQUIRED";
    this.details = decision.details;
    this.entitlement = input.entitlement;
    this.subject = input.subject;
  }
}

/**
 * Create an explicit allow decision.
 */
export function allowEntitlement(): EntitlementAllowedDecision {
  return { allowed: true };
}

/**
 * Create an explicit deny decision.
 */
export function denyEntitlement(
  reasonOrDecision?: string | Omit<EntitlementDeniedDecision, "allowed">,
): EntitlementDeniedDecision {
  if (typeof reasonOrDecision === "string") {
    return { allowed: false, reason: reasonOrDecision };
  }

  return {
    allowed: false,
    ...reasonOrDecision,
  };
}

/**
 * Create an entitlement port from an app-owned resolver.
 */
export function createEntitlements<
  TKey extends EntitlementKey = EntitlementKey,
  TContext = unknown,
>(
  options: CreateEntitlementsOptions<TKey, TContext>,
): EntitlementsPort<TKey, TContext> {
  const observeDecision = createDecisionObserver(options.onDecision);

  async function evaluate(
    input: EntitlementCheckInput<TKey>,
    checkOptions: EntitlementCheckOptions<TContext> | undefined,
  ): Promise<EntitlementDecision> {
    const startedAt = entitlementNow();

    try {
      const decision = normalizeEntitlementDecision(
        await options.inspect(input),
      );
      observeDecision({
        source: checkOptions?.source ?? "inspect",
        ctx: checkOptions?.ctx,
        entitlement: input.entitlement,
        subject: input.subject,
        decision,
        durationMs: entitlementNow() - startedAt,
      });

      return decision;
    } catch (error) {
      observeDecision({
        source: checkOptions?.source ?? "inspect",
        ctx: checkOptions?.ctx,
        entitlement: input.entitlement,
        subject: input.subject,
        error,
        durationMs: entitlementNow() - startedAt,
      });

      throw error;
    }
  }

  return {
    async can(input, checkOptions) {
      return (await evaluate(input, { ...checkOptions, source: "can" }))
        .allowed;
    },
    inspect(input, checkOptions) {
      return evaluate(input, {
        ...checkOptions,
        source: checkOptions?.source ?? "inspect",
      });
    },
  };
}

/**
 * Create a static entitlement port for tests, starters, and simple apps.
 */
export function createStaticEntitlements<
  TKey extends EntitlementKey = EntitlementKey,
  TContext = unknown,
>(
  options: CreateStaticEntitlementsOptions<TKey, TContext>,
): EntitlementsPort<TKey, TContext> {
  const subjectKey = options.subjectKey ?? defaultSubjectKey;

  return createEntitlements<TKey, TContext>({
    inspect(input) {
      const granted = options.grants[subjectKey(input.subject)] ?? [];
      if (granted.includes(input.entitlement)) return allowEntitlement();

      return denyEntitlement({
        reason: `Entitlement "${input.entitlement}" is not granted.`,
        code: "ENTITLEMENT_NOT_GRANTED",
        details: {
          entitlement: input.entitlement,
          subject: input.subject,
        },
        ...options.denied,
      });
    },
    onDecision: options.onDecision,
  });
}

/**
 * Require an entitlement or throw a framework-owned 403 error.
 */
export async function requireEntitlement<
  TKey extends EntitlementKey = EntitlementKey,
>(
  ctx: EntitlementsContext<TKey>,
  input: EntitlementCheckInput<TKey>,
  options?: RequireEntitlementOptions<TKey>,
): Promise<EntitlementAllowedDecision> {
  const decision = await ctx.ports.entitlements.inspect(input, {
    ctx,
    source: "require",
  });
  if (decision.allowed) return decision;

  throw options?.error
    ? options.error(decision, input)
    : new EntitlementRequiredError(input, decision);
}

function normalizeEntitlementDecision(
  result: EntitlementResolverResult,
): EntitlementDecision {
  if (typeof result === "boolean") {
    return result ? allowEntitlement() : denyEntitlement();
  }

  return result;
}

function entitlementNow(): number {
  return typeof performance !== "undefined" ? performance.now() : Date.now();
}

function contextStringField(ctx: unknown, field: string): string | undefined {
  if (!ctx || typeof ctx !== "object") return undefined;
  const value = (ctx as Record<string, unknown>)[field];
  return typeof value === "string" ? value : undefined;
}

function enrichObservation<TContext, TKey extends EntitlementKey>(
  observation: EntitlementDecisionObservation<TContext, TKey>,
): EntitlementDecisionObservation<TContext, TKey> {
  return {
    ...observation,
    requestId: contextStringField(observation.ctx, "requestId"),
    traceId: contextStringField(observation.ctx, "traceId"),
    spanId: contextStringField(observation.ctx, "spanId"),
    parentSpanId: contextStringField(observation.ctx, "parentSpanId"),
    traceparent: contextStringField(observation.ctx, "traceparent"),
  };
}

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
  return (
    value !== null &&
    (typeof value === "object" || typeof value === "function") &&
    typeof (value as { then?: unknown }).then === "function"
  );
}

function createDecisionObserver<TContext, TKey extends EntitlementKey>(
  observer: EntitlementDecisionObserver<TContext, TKey> | undefined,
): (observation: EntitlementDecisionObservation<TContext, TKey>) => void {
  return (observation) => {
    if (!observer) return;

    try {
      const result = observer(enrichObservation(observation));
      if (isPromiseLike(result)) {
        Promise.resolve(result).catch(() => undefined);
      }
    } catch {
      // Observers are diagnostic only and must not affect entitlement checks.
    }
  };
}

function defaultSubjectKey(subject: EntitlementSubject): string {
  return `${subject.type}:${subject.id}`;
}
