package cloud.nstart.terravault.ordinis.restapi.auth;

import cloud.nstart.terravault.ordinis.auth.OrdinisAuthProperties;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

/**
 * Realm-role enforcement для workflow actions (approve/reject/publish
 * как record draft, так и schema draft).
 *
 * <p>Naming convention: colon-separated {@code ordinis:<domain>:<action>}
 * pattern (consistent с scope ACL {@code ordinis:client:public}).
 * Domain-specific роли для clear separation между record и schema workflows:
 * <ul>
 *   <li>{@link #SCHEMA_REVIEWER} {@value SCHEMA_REVIEWER} — gate на
 *       schema draft decide (approve / request_changes / reject).</li>
 *   <li>{@link #SCHEMA_PUBLISHER} {@value SCHEMA_PUBLISHER} — gate на
 *       publish approved schema draft → live. У records publish
 *       auto-выполняется при approve, отдельной step'и нет.</li>
 *   <li>{@link #RECORD_REVIEWER} {@value RECORD_REVIEWER} — gate на
 *       record draft approve / reject (Approval Workflow v2).</li>
 * </ul>
 *
 * <p>Maker side (create / submit / withdraw) пока не role-gated — любой
 * authenticated user может создавать drafts. Backend гейтит
 * {@code self_approve_forbidden} (maker != reviewer) как primary defence
 * для maker-checker invariant.
 *
 * <p>JWT источник: configurable claim path через
 * {@link OrdinisAuthProperties#rolesClaim()} — тот же что у
 * {@code ScopeContext}. Default {@code realm_access.roles} (Keycloak realm
 * roles). Можно настроить на {@code resource_access.ordinis.roles} если
 * Keycloak realm использует client-scoped roles (mapped к {@code ordinis}
 * client).
 *
 * <p><b>Migration note (Phase 1d enforcement):</b> Keycloak realm должен
 * иметь созданные роли, и users должны их получить. Иначе все decide /
 * publish операции вернут {@code 403 forbidden_role}.
 *
 * <p>Composite role idea: {@code ordinis:admin} = combined из всех трёх
 * + scope role'ов — single assignment grants full workflow access.
 */
@Component
public class WorkflowRoles {

  private static final Logger log = LoggerFactory.getLogger(WorkflowRoles.class);

  /** Approve / reject / request_changes schema draft (Phase 1c workflow). */
  public static final String SCHEMA_REVIEWER = "ordinis:schema:reviewer";

  /** Publish approved schema draft → live (bump dictionary schema version). */
  public static final String SCHEMA_PUBLISHER = "ordinis:schema:publisher";

  /** Approve / reject record draft (Approval Workflow v2). */
  public static final String RECORD_REVIEWER = "ordinis:record:reviewer";

  /**
   * Super-role: holder автоматически проходит любой role check без явного
   * назначения отдельных workflow roles. Convenience для small teams где
   * один-два user'а имеют full access — assign one role вместо трёх.
   *
   * <p>Composite role в Keycloak — лучше; super-role override — fallback
   * для cases где composite ещё не настроен.
   */
  public static final String ADMIN = "ordinis:admin";

  private final OrdinisAuthProperties props;

  /**
   * Spring autowire constructor. Без @Autowired Spring randomly выбирает
   * один из multiple ctors — был bug что выбирался no-arg test ctor
   * → props=null → fallback на default realm_access.roles (несмотря на
   * env var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles).
   */
  @org.springframework.beans.factory.annotation.Autowired
  public WorkflowRoles(OrdinisAuthProperties props) {
    this.props = props;
  }

  /** Test ctor — без OrdinisAuthProperties (defaults to realm_access.roles). */
  WorkflowRoles() {
    this.props = null;
  }

  /** Reads roles из configured claim path. Anonymous → empty. */
  public Set<String> currentRoles() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (!(auth instanceof JwtAuthenticationToken jwt)) {
      return Set.of();
    }
    return extractRoles(jwt.getToken(), rolesClaimPath());
  }

  /**
   * True если у actor'а есть указанная workflow role ИЛИ super-role
   * {@link #ADMIN}. Admin грантит full workflow access без явного
   * назначения отдельных reviewer/publisher ролей.
   */
  public boolean hasRole(String role) {
    Set<String> roles = currentRoles();
    return roles.contains(role) || roles.contains(ADMIN);
  }

  /**
   * Throw {@code 403 forbidden_role} если у actor'а нет role.
   * Service-layer fail-fast guard.
   */
  public void requireRole(String role) {
    if (!hasRole(role)) {
      log.debug("Access denied: missing required workflow role '{}' at claim path '{}'",
          role, rolesClaimPath());
      throw OrdinisException.forbidden(
          "forbidden_role",
          "Требуется realm role: " + role);
    }
  }

  private String rolesClaimPath() {
    return props != null ? props.rolesClaim() : "realm_access.roles";
  }

  /** Reads JWT claim по nested path (e.g. "realm_access.roles" or
   *  "resource_access.ordinis.roles") и возвращает roles как Set<String>. */
  static Set<String> extractRoles(Jwt token, String path) {
    Object current = readClaimByPath(token, path);
    if (!(current instanceof Collection<?> rolesCol)) {
      return Set.of();
    }
    LinkedHashSet<String> out = new LinkedHashSet<>();
    for (Object o : rolesCol) {
      if (o instanceof String s) {
        out.add(s);
      }
    }
    return out;
  }

  /** Default-path overload для тестов и backward compat. */
  static Set<String> extractRoles(Jwt token) {
    return extractRoles(token, "realm_access.roles");
  }

  /** Nested JWT claim resolver. Mirror {@code ScopeContext.readClaimByPath}. */
  private static Object readClaimByPath(Jwt token, String path) {
    String[] parts = path.split("\\.");
    Object current = token.getClaim(parts[0]);
    for (int i = 1; i < parts.length && current != null; i++) {
      if (current instanceof Map<?, ?> m) {
        current = m.get(parts[i]);
      } else {
        return null;
      }
    }
    return current;
  }
}
