package regfishapi

import (
	"errors"
	"net/http"
	"strings"
)

// Message prefixes the API uses for its authorization denials.
//
// Each is matched as a prefix, never as a regular expression: two of the three
// 403 templates are character-identical apart from their leading noun phrase,
// so a loosely anchored pattern would cross-match them.
const (
	guardianPrefix         = "Blocked by Domain Guardian"                 // records.ts:178, dnssec.ts:130
	lacksPermissionPrefix  = "API key lacks permission '"                 // routes.ts:119
	acmeNotPermittedPrefix = "ACME credential is not permitted to call '" // routes.ts:105
	keyNotPermittedPrefix  = "API key is not permitted to call '"         // routes.ts:120
	// acme.ts refuses a capability overstep with codes.ResourceRecordNotAllowed,
	// the same code an ordinary CNAME-at-apex validation error uses. This prefix
	// is what separates the two.
	acmeCapabilityPrefix = "ACME credential may only " // acme.ts:164, 184, 252, 278, 292
)

// Denial classifies why the API refused a request.
//
// The classification is derived from the response, never from a local copy of
// the server's permission model: the pair (HTTP status, presence and value of
// the "code" member) does the work, and message text only refines an already
// established denial. An unrecognised message therefore degrades to the
// coarser value, never to DenialNone.
type Denial int

const (
	// DenialNone means err is not a recognised authorization failure. It is
	// also returned for errors that are not *APIError and for denial shapes
	// this version does not know about — never read it as "the request was
	// permitted".
	DenialNone Denial = iota
	// DenialUnauthenticated is HTTP 401: the key was absent, malformed,
	// unknown or deactivated. The API does not distinguish these four.
	DenialUnauthenticated
	// DenialPermission means the key does not hold the permission the
	// operation requires. MissingPermission names it when the API did.
	DenialPermission
	// DenialScope means a scope-bound platform credential (see
	// ScopeACMEDNSChallenge) may not call this operation at all. Granting the
	// key more permissions does not help; a different key does.
	DenialScope
	// DenialUnmappedOperation means the server has no permission mapping for
	// the operation and denied it fail-closed. This is a server-side gap, not
	// an under-scoped key: no grant lifts it, and it affects every scoped key
	// at once. Worth reporting to regfish.
	DenialUnmappedOperation
	// DenialGuardian means the Domain-Guardian blocked the change. It is
	// permanent until a human confirms the change in the regfish dash or
	// exempts this API key there.
	DenialGuardian
	// DenialQuota means a rate limit or daily budget was exhausted. The API
	// sends no Retry-After header, and the budgets reset on the UTC calendar
	// day.
	//
	// Only the nameserver-change and auth-code budgets reach a client this way.
	// The ACME limiter also answers 429, but 429 is not among the documented
	// responses of the record endpoints, so the API's response validator
	// replaces it with a generic server error before it leaves the building —
	// see the note on IsDomainGuardianBlocked.
	DenialQuota
)

// String implements fmt.Stringer.
func (d Denial) String() string {
	switch d {
	case DenialUnauthenticated:
		return "unauthenticated"
	case DenialPermission:
		return "permission"
	case DenialScope:
		return "scope"
	case DenialUnmappedOperation:
		return "unmapped-operation"
	case DenialGuardian:
		return "domain-guardian"
	case DenialQuota:
		return "quota"
	default:
		return "none"
	}
}

// DenialOf classifies err. It returns DenialNone for errors that are not
// *APIError and for failures that are not authorization decisions.
func DenialOf(err error) Denial {
	var e *APIError
	if !errors.As(err, &e) {
		return DenialNone
	}

	// The Domain-Guardian is matched first and independently of the status:
	// domains.ts serves it as 403 with CodeDomainGuardBlocked, records.ts and
	// dnssec.ts as 500 with CodeFailed and the text in "error". Checking the
	// code before the prose means this upgrades to a purely structural match by
	// itself once the server emits 18002 everywhere.
	if e.Code == CodeDomainGuardBlocked ||
		strings.HasPrefix(e.Detail, guardianPrefix) ||
		strings.HasPrefix(e.Message, guardianPrefix) {
		return DenialGuardian
	}

	// A scope-bound credential overstepping its capability is refused by
	// acme.ts with a record-level code rather than a 403, so it needs its own
	// arm. The prefix is load-bearing: the same status and code carry ordinary
	// payload validation errors, which are the caller's to fix.
	if e.StatusCode == http.StatusExpectationFailed &&
		e.Code == CodeResourceRecordNotAllowed &&
		strings.HasPrefix(e.Detail, acmeCapabilityPrefix) {
		return DenialScope
	}

	switch e.StatusCode {
	case http.StatusUnauthorized:
		return DenialUnauthenticated
	case http.StatusTooManyRequests:
		return DenialQuota
	case http.StatusForbidden:
		switch {
		case strings.HasPrefix(e.Message, acmeNotPermittedPrefix):
			return DenialScope
		case strings.HasPrefix(e.Message, keyNotPermittedPrefix):
			return DenialUnmappedOperation
		default:
			// Every other 403 is still an authorization decision about this
			// credential, including message templates a future server version
			// may introduce.
			return DenialPermission
		}
	default:
		return DenialNone
	}
}

// IsPermissionDenied reports whether the API refused err because of what this
// credential is allowed to do: a missing permission, a scope-bound credential,
// or an operation the server has not mapped.
//
// It is false for a Domain-Guardian block, which has a different remedy — see
// IsDomainGuardianBlocked — and false for HTTP 401, which means the key was not
// accepted at all. Use DenialOf when the three sub-cases need different
// handling.
func IsPermissionDenied(err error) bool {
	switch DenialOf(err) {
	case DenialPermission, DenialScope, DenialUnmappedOperation:
		return true
	default:
		return false
	}
}

// IsDomainGuardianBlocked reports whether the Domain-Guardian blocked the
// change. The block is permanent until a human confirms the change in the
// regfish dash or exempts this API key on the domain's protection page.
//
// The API reports it in two incompatible shapes: HTTP 403 with
// CodeDomainGuardBlocked on the domain endpoints, but HTTP 500 with CodeFailed
// on the DNS record endpoints, where status and code alone cannot tell it from
// a genuine server fault. Callers that retry on 5xx must check this first, or
// they will retry a decision that can never succeed.
//
// One case is beyond reach: the guardian block on PUT /dns/{domain}/dnssec is
// also served as HTTP 500, but 500 is not among that operation's documented
// responses, so the API's own response validator discards the body and sends a
// generic server error instead. Nothing in the response survives to identify
// it, here or in any other client. Both this and the ACME quota gap are
// server-side defects (see docs/API-REVIEW-2026-08-01.md, finding H4) rather
// than limits of this classification.
func IsDomainGuardianBlocked(err error) bool { return DenialOf(err) == DenialGuardian }

// IsPermanent reports whether err is a denial that no retry can clear —
// authentication, permission, scope, an unmapped operation or a
// Domain-Guardian block. A quota denial is not permanent: it clears when the
// window or the calendar day rolls over.
//
// IsPermanent is one-directional. False means "not known to be permanent", not
// "safe to retry": several policy refusals the library deliberately does not
// classify are permanent too, notably the plan and chargeable-TLD gates that
// share HTTP 409 and CodeNameserverChangeNotAllowed with a transient
// transfer-in-progress. Cap your retries either way.
func IsPermanent(err error) bool {
	switch DenialOf(err) {
	case DenialNone, DenialQuota:
		return false
	default:
		return true
	}
}

// MissingPermission returns the permission the API named when it denied the
// request, e.g. "dns:write", and reports whether the denial named one at all.
//
// Only the missing-permission denial carries a permission; scope and
// unmapped-operation denials name an operation instead — see DeniedOperation.
//
// The value comes from the response, so it is always current: the library keeps
// no local operation-to-permission table.
func MissingPermission(err error) (string, bool) {
	var e *APIError
	if !errors.As(err, &e) {
		return "", false
	}
	return quotedAfter(e.Message, lacksPermissionPrefix)
}

// DeniedOperation returns the operationId the API named when it refused a
// scope-bound credential or an operation it has no permission mapping for, and
// reports whether the denial named one.
func DeniedOperation(err error) (string, bool) {
	var e *APIError
	if !errors.As(err, &e) {
		return "", false
	}
	if op, ok := quotedAfter(e.Message, acmeNotPermittedPrefix); ok {
		return op, true
	}
	return quotedAfter(e.Message, keyNotPermittedPrefix)
}

// quotedAfter returns the text between the single quote that ends prefix and
// the next single quote in s.
func quotedAfter(s, prefix string) (string, bool) {
	if !strings.HasPrefix(s, prefix) {
		return "", false
	}
	rest := s[len(prefix):]
	end := strings.IndexByte(rest, '\'')
	if end <= 0 {
		return "", false
	}
	return rest[:end], true
}

// Reason returns the API's human readable explanation, without the status and
// code prefix that Error adds.
//
// The API puts the text in "message" on some endpoints and in "error" on
// others — the same Domain-Guardian block uses "message" on the domain
// endpoints and "error" on the record and DNSSEC endpoints — so Reason reads
// both.
//
// When both are set, "error" comes first: the one response that populates both
// carries the diagnosis there and a follow-up instruction in "message"
// ("Invalid JSON payload received." + "Please check your request and try
// again."), so the other order reads as though the instruction caused the
// error.
func (e *APIError) Reason() string {
	switch {
	case e.Detail != "" && e.Message != "":
		return e.Detail + " " + e.Message
	case e.Detail != "":
		return e.Detail
	default:
		return e.Message
	}
}
