import { ForbiddenError, NotFoundError, UnauthenticatedError } from '../errors/index.js';
import { isAnonymous, type SecurityContext } from './context.js';
import type { Permission } from './permissions.js';

/**
 * The resource an action targets. Every field is optional: supply the ones that
 * apply and `authorize()` checks each against the actor's scope.
 */
export interface ResourceTarget {
  readonly collegeId?: string | null;
  readonly programId?: string | null;
  readonly sectionOfferingId?: string | null;
  readonly studentId?: string | null;
  readonly facultyProfileId?: string | null;
  /** The owning user, for records that belong directly to an account. */
  readonly ownerUserId?: string | null;
}

export interface AuthorizeOptions {
  /**
   * When true, a scope mismatch raises NotFoundError instead of ForbiddenError
   * so the caller cannot learn that the record exists. Default: true for reads
   * of individual records. Pass `false` when the caller demonstrably knows the
   * resource exists (e.g. acting on their own record).
   */
  readonly maskAsNotFound?: boolean;
}

export function hasPermission(ctx: SecurityContext, permission: Permission): boolean {
  return ctx.isSuperAdmin || ctx.permissions.has(permission);
}

export function hasAnyPermission(ctx: SecurityContext, permissions: readonly Permission[]): boolean {
  return permissions.some((permission) => hasPermission(ctx, permission));
}

export function hasAllPermissions(ctx: SecurityContext, permissions: readonly Permission[]): boolean {
  return permissions.every((permission) => hasPermission(ctx, permission));
}

/**
 * Whether the actor's scope admits the target. A superadmin is unrestricted.
 *
 * An actor holding *no* scope of a given kind is unrestricted **for that kind**
 * — this is how institution-wide roles (Registrar, Admissions) work: they hold
 * no college assignment and so are not college-limited. Roles that must be
 * limited (Dean, Faculty, Student) always carry a non-empty scope, and an
 * empty scope for them means "assigned to nothing", which correctly matches
 * nothing because the target id will not be found in an empty list.
 *
 * The distinction is made explicit by `scopeKindApplies`: a scope kind
 * restricts only when the actor holds that kind of assignment, or when the
 * actor is a student (who is always self-limited).
 */
export function isInScope(ctx: SecurityContext, target: ResourceTarget): boolean {
  if (ctx.isSuperAdmin) return true;
  const { scope } = ctx;

  if (target.collegeId != null && scope.collegeIds.length > 0) {
    if (!scope.collegeIds.includes(target.collegeId)) return false;
  }

  if (target.programId != null && scope.programIds.length > 0) {
    if (!scope.programIds.includes(target.programId)) return false;
  }

  if (target.sectionOfferingId != null && scope.sectionOfferingIds.length > 0) {
    if (!scope.sectionOfferingIds.includes(target.sectionOfferingId)) return false;
  }

  // A student is always confined to their own record, without exception.
  if (scope.studentId !== null && target.studentId != null) {
    if (scope.studentId !== target.studentId) return false;
  }

  if (target.facultyProfileId != null && scope.facultyProfileId !== null) {
    if (scope.facultyProfileId !== target.facultyProfileId) return false;
  }

  if (target.ownerUserId != null && target.ownerUserId !== ctx.userId) {
    // Owning-user checks only pass for the owner unless a broader permission
    // was already verified by the caller, which is the documented contract.
    return false;
  }

  return true;
}

/**
 * The single authorization entry point. Throws on failure; returns void on
 * success. Call it in every route handler *before* any data access.
 *
 * @throws UnauthenticatedError when the caller has no session.
 * @throws ForbiddenError when the permission is missing.
 * @throws NotFoundError when the permission is held but the target is out of
 *         scope and masking is enabled.
 */
export function authorize(
  ctx: SecurityContext,
  permission: Permission,
  target?: ResourceTarget,
  options: AuthorizeOptions = {},
): void {
  if (isAnonymous(ctx)) {
    throw new UnauthenticatedError();
  }

  if (!hasPermission(ctx, permission)) {
    throw new ForbiddenError('You do not have permission to perform this action.', {
      meta: { requiredPermission: permission },
    });
  }

  if (target && !isInScope(ctx, target)) {
    const maskAsNotFound = options.maskAsNotFound ?? true;
    if (maskAsNotFound) {
      throw new NotFoundError();
    }
    throw new ForbiddenError('This resource is outside the scope assigned to your account.', {
      meta: { requiredPermission: permission },
    });
  }
}

/** Authorizes when *any* of the listed permissions is held. */
export function authorizeAny(
  ctx: SecurityContext,
  permissions: readonly Permission[],
  target?: ResourceTarget,
  options: AuthorizeOptions = {},
): void {
  if (isAnonymous(ctx)) {
    throw new UnauthenticatedError();
  }
  if (!hasAnyPermission(ctx, permissions)) {
    throw new ForbiddenError('You do not have permission to perform this action.', {
      meta: { requiredAnyOf: permissions },
    });
  }
  if (target && !isInScope(ctx, target)) {
    if (options.maskAsNotFound ?? true) throw new NotFoundError();
    throw new ForbiddenError('This resource is outside the scope assigned to your account.');
  }
}

/**
 * Non-throwing variant, for deciding whether to render an action in the UI.
 * This is a convenience for presentation only — the server-side `authorize()`
 * call remains mandatory (SPEC.md §0.9).
 */
export function can(ctx: SecurityContext, permission: Permission, target?: ResourceTarget): boolean {
  if (isAnonymous(ctx)) return false;
  if (!hasPermission(ctx, permission)) return false;
  if (target && !isInScope(ctx, target)) return false;
  return true;
}

/**
 * The scope filter a repository must apply for this actor. Returning `null` for
 * a dimension means "unrestricted on that dimension"; returning an array means
 * "restricted to exactly these ids", including the empty array, which correctly
 * matches nothing.
 */
export interface ScopeFilter {
  readonly collegeIds: readonly string[] | null;
  readonly programIds: readonly string[] | null;
  readonly sectionOfferingIds: readonly string[] | null;
  readonly studentId: string | null;
}

export function scopeFilterFor(ctx: SecurityContext): ScopeFilter {
  if (ctx.isSuperAdmin) {
    return { collegeIds: null, programIds: null, sectionOfferingIds: null, studentId: null };
  }
  const { scope } = ctx;
  return {
    collegeIds: scope.collegeIds.length > 0 ? scope.collegeIds : null,
    programIds: scope.programIds.length > 0 ? scope.programIds : null,
    sectionOfferingIds: scope.sectionOfferingIds.length > 0 ? scope.sectionOfferingIds : null,
    studentId: scope.studentId,
  };
}
