package reconcile

import (
	"encoding/json"
	"log/slog"
	"reflect"
	"strconv"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
)

// SourceKind records where an intent came from — audit/debug ONLY. Classification
// does NOT depend on it: a destructive desired-state delta and a destructive one-shot
// job are gated identically (the agent distrusts hub desired state for destructive
// change, not just jobs — doc 03 §4).
type SourceKind string

const (
	SourceDesiredDelta SourceKind = "desired_delta"
	SourceOneShotJob   SourceKind = "one_shot_job"
)

// Intent is an intended mutation presented to the gate. For benign reconcile actions
// the engine builds one per planned Action; destructive intents (jobs / deltas) carry
// their op class + canonical params for binding.
type Intent struct {
	Class   OpClass
	HostID  string
	GuestID string // blob-style guest id ("" = host-scoped); matches OpBlob.target.guest_id
	VMID    int    // numeric, for queue routing (0 = host-scoped)
	// ParamsJSON is the canonical params (matching the signed blob's `params`) used for
	// op-to-action binding on destructive ops. Nil for benign actions (not bound).
	ParamsJSON json.RawMessage
	// Provenance is AGENT-INTERNAL only (never hub-sourced) — see classify.go.
	Provenance Provenance
	Source     SourceKind
}

// SignedOp is the opaque operator-signed blob+signature pair the hub queues (doc 04
// §5). The agent never trusts it until authz.Verifier.Verify passes.
type SignedOp struct {
	Blob []byte // the canonical OpBlob JSON bytes (verified over RAW bytes)
	Sig  []byte // the armored SSHSIG
}

// RefuseReason is a stable, machine-readable gate refusal reason.
type RefuseReason string

const (
	ReasonBenign           RefuseReason = "benign"            // allowed, no signature needed
	ReasonSigned           RefuseReason = "signed"            // allowed by a verified op
	ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature
	ReasonRejected         RefuseReason = "rejected"          // signature failed authz verification
	ReasonRoleDenied       RefuseReason = "role_denied"       // signer role not authorized for this op class
	ReasonBindingMismatch  RefuseReason = "binding_mismatch"  // signature/confirmation is for a different action/device

	// Storage-wipe customer-confirmable tier (user-data only).
	ReasonCustomerConfirmed   RefuseReason = "customer_confirmed"   // allowed by the customer's durable-id-bound confirmation
	ReasonPendingConfirmation RefuseReason = "pending_confirmation" // user-data wipe awaiting the customer's confirmation
)

// Decision is the gate verdict.
type Decision struct {
	Allowed     bool
	Disposition Disposition
	Reason      RefuseReason
	// Verified is the authenticated op when a signature authorized the action.
	Verified *authz.VerifiedOp
	// Err is the underlying authz rejection (errors.Is-friendly: ErrUnknownSigner,
	// ErrExpired, ErrReplay, …) when Reason == ReasonRejected.
	Err error
}

// OpVerifier is the crypto verifier seam — *authz.Verifier in production; a fake in
// gate unit tests. The gate never re-implements any crypto; it only consumes the
// verdict and enforces the policy layer on top (role-scoping + op-to-action binding).
type OpVerifier interface {
	Verify(blob, sigArmored []byte) (*authz.VerifiedOp, error)
}

// AuditSink records every gate decision to the customer-visible audit log. Audit is a
// SIGNAL, never the guard (doc 03 §4 / doc 04 §5): a compromised hub could suppress a
// notice, which is exactly why the signature — not the audit — is the control.
type AuditSink interface {
	Record(rec AuditRecord)
}

// AuditRecord is one audited gate decision.
type AuditRecord struct {
	Time        time.Time
	Class       OpClass
	HostID      string
	GuestID     string
	Source      SourceKind
	Disposition Disposition
	Allowed     bool
	Reason      RefuseReason
	KeyID       string // matched signer's key id, when signed
	Nonce       string // the op nonce, when signed
	// DurableID is the device's durable id on a customer-confirmable storage wipe — the load-bearing
	// audit detail ("who/what/when + the durable id") so a customer-authorized wipe is attributable.
	DurableID string
}

// Gate is the reversibility gate: it sits in front of the per-guest queue's executor
// so EVERY mutation passes it. Benign intents are allowed unsigned; destructive
// intents require a verified, role-authorized, action-bound operator signature, else
// they are refused with pending_signature and never executed.
type Gate struct {
	verifier OpVerifier // may be nil (no signers pinned) → destructive is always pending_signature
	hostID   string
	audit    AuditSink
	logger   *slog.Logger
}

// NewGate builds a gate. verifier may be nil when no signers are configured (the
// common slice-4 state) — then there is nothing destructive to authorize and any
// destructive intent is refused pending_signature. audit/logger default to no-ops.
func NewGate(verifier OpVerifier, hostID string, audit AuditSink, logger *slog.Logger) *Gate {
	if audit == nil {
		audit = noopAudit{}
	}
	if logger == nil {
		logger = slog.New(slog.NewTextHandler(discard{}, nil))
	}
	return &Gate{verifier: verifier, hostID: hostID, audit: audit, logger: logger}
}

// Authorize classifies the intent and, for destructive intents, runs the full
// consuming-layer policy over the verifier verdict. It writes the decision to the
// audit log and returns it. It NEVER executes anything — the caller dispatches an
// Allowed decision onto the queue.
func (g *Gate) Authorize(intent Intent, signed *SignedOp) Decision {
	disp := Classify(intent.Class, intent.Provenance)

	// Benign: allowed without a signature.
	if disp == Benign {
		d := Decision{Allowed: true, Disposition: Benign, Reason: ReasonBenign}
		g.record(intent, d)
		return d
	}

	// Destructive from here: a verified, role-authorized, action-bound signature is
	// mandatory. Missing signature OR no pinned verifier → pending_signature (refuse).
	if signed == nil || g.verifier == nil {
		d := Decision{Allowed: false, Disposition: Destructive, Reason: ReasonPendingSignature}
		g.record(intent, d)
		return d
	}

	// Crypto + namespace + allow-list + target + time + nonce — the LOCKED authz
	// pipeline. The nonce is consumed (recorded) only if this passes.
	vop, err := g.verifier.Verify(signed.Blob, signed.Sig)
	if err != nil {
		d := Decision{Allowed: false, Disposition: Destructive, Reason: ReasonRejected, Err: err}
		g.record(intent, d)
		return d
	}

	// Role-scoping (the slice-4 job per verifier.go): the signer's pinned role must be
	// authorized for THIS op class.
	if !roleAuthorizes(vop.Signer.Role, intent.Class) {
		d := Decision{Allowed: false, Disposition: Destructive, Reason: ReasonRoleDenied, Verified: vop}
		g.record(intent, d)
		return d
	}

	// Op-to-action binding: the verified op must name THIS exact action (op + target +
	// params) — a signature for "restore guest X" cannot authorize destroying guest Y.
	if !g.bindsToAction(vop, intent) {
		d := Decision{Allowed: false, Disposition: Destructive, Reason: ReasonBindingMismatch, Verified: vop}
		g.record(intent, d)
		return d
	}

	d := Decision{Allowed: true, Disposition: Destructive, Reason: ReasonSigned, Verified: vop}
	g.record(intent, d)
	return d
}

// StorageWipeAuthz is the input to AuthorizeStorageWipe. Role is the agent's AUTHORITATIVE
// classification (StorageRole* in classify.go) — never a caller's claim. DeviceDurableID is the
// durable id the AGENT re-resolved for the device. Confirmed/ConfirmDurableID are the customer's
// informed-confirmation, honored ONLY for user-data and ONLY when ConfirmDurableID matches the agent's
// DeviceDurableID (a confirmation for one disk can't wipe another).
type StorageWipeAuthz struct {
	HostID           string
	Role             string // StorageRoleSystem | StorageRoleBackup | StorageRoleUserData
	TargetID         string // storage target identity for the operator-signed (system/backup) binding
	DeviceDurableID  string // agent-re-resolved durable id of the device ("" if unresolvable)
	Confirmed        bool
	ConfirmDurableID string
}

// AuthorizeStorageWipe tiers a data-bearing storage wipe by the agent's role verdict:
//
//   - user-data → CUSTOMER-CONFIRMABLE: allowed iff the request carries an explicit customer
//     confirmation BOUND to the device's durable id (the agent re-resolved DeviceDurableID and it
//     matches ConfirmDurableID). No operator signature. The wipe is recorded in the customer-visible
//     audit log with the durable id.
//   - system / backup / anything else → the standard DESTRUCTIVE path (operator-signature /
//     pending_signature). The Confirmed flag is DELIBERATELY IGNORED: no customer confirmation can
//     wipe the appliance's system storage or the backup safety-net. A compromised controller
//     asserting confirmed:true is refused here BY ROLE — role is the agent's, never the caller's.
//
// signed is the operator signature for the system/backup path (nil on the inline format path → always
// pending_signature; the signed completion runs via the signed-jobs runner).
func (g *Gate) AuthorizeStorageWipe(in StorageWipeAuthz, signed *SignedOp) Decision {
	if in.Role == StorageRoleUserData {
		if !in.Confirmed {
			d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonPendingConfirmation}
			g.recordWipe(in, d)
			return d
		}
		// Durable-id binding: the confirmation must name THIS exact device (re-resolved by the agent).
		// An unresolved/empty id fails safe (refuse), and a mismatch can't be retargeted to another disk.
		if in.DeviceDurableID == "" || in.ConfirmDurableID == "" || in.ConfirmDurableID != in.DeviceDurableID {
			d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonBindingMismatch}
			g.recordWipe(in, d)
			return d
		}
		d := Decision{Allowed: true, Disposition: CustomerConfirmable, Reason: ReasonCustomerConfirmed}
		g.recordWipe(in, d)
		return d
	}
	// system / backup / unknown → operator-signature path. Confirmed is NOT consulted.
	intent := IntentForStorageDestructive(ClassStorageWipe, in.HostID, in.TargetID, nil, SourceOneShotJob)
	return g.Authorize(intent, signed)
}

// recordWipe audits a customer-confirmable storage-wipe decision (allowed or refused) with the
// device's durable id — the customer-visible "who/what/when + durable id" record.
func (g *Gate) recordWipe(in StorageWipeAuthz, d Decision) {
	g.audit.Record(AuditRecord{
		Time:        time.Now().UTC(),
		Class:       ClassStorageWipe,
		HostID:      in.HostID,
		GuestID:     in.TargetID,
		Source:      SourceOneShotJob,
		Disposition: d.Disposition,
		Allowed:     d.Allowed,
		Reason:      d.Reason,
		DurableID:   in.DeviceDurableID,
	})
	g.logger.Info("gate decision (storage wipe)",
		"role", in.Role, "disposition", d.Disposition, "allowed", d.Allowed,
		"reason", d.Reason, "durable_id", in.DeviceDurableID)
}

// roleAuthorizes enforces the doc 04 §4 two-key role model: the cold recovery key
// authorizes ONLY key-rotation re-pins; the operational key authorizes ordinary
// destructive ops AND planned key-rotation.
func roleAuthorizes(role authz.KeyRole, class OpClass) bool {
	if class == ClassKeyRotation {
		return role == authz.RoleOperational || role == authz.RoleRecovery
	}
	return role == authz.RoleOperational
}

// bindsToAction checks the verified op names this exact action: host (already checked
// by the verifier, re-asserted here), guest, op class, and params. This is the binding
// BEYOND the verifier's target check (doc 04 §2.3 binds host; this binds the full
// action).
func (g *Gate) bindsToAction(vop *authz.VerifiedOp, intent Intent) bool {
	if vop.HostID != g.hostID || vop.HostID != intent.HostID {
		return false
	}
	if vop.GuestID != intent.GuestID {
		return false
	}
	if vop.Op != string(intent.Class) {
		return false
	}
	return paramsEqual(vop.Params, intent.ParamsJSON)
}

// paramsEqual compares two JSON param objects semantically (key order / whitespace
// independent). Absent params on both sides ({} or empty) compare equal.
func paramsEqual(a, b json.RawMessage) bool {
	ax, aok := decodeParams(a)
	bx, bok := decodeParams(b)
	if !aok || !bok {
		return false
	}
	return reflect.DeepEqual(ax, bx)
}

func decodeParams(p json.RawMessage) (any, bool) {
	if len(p) == 0 {
		return map[string]any{}, true // absent == empty object
	}
	var v any
	if err := json.Unmarshal(p, &v); err != nil {
		return nil, false
	}
	if v == nil {
		return map[string]any{}, true // explicit null == empty
	}
	return v, true
}

func (g *Gate) record(intent Intent, d Decision) {
	rec := AuditRecord{
		Time:        time.Now().UTC(),
		Class:       intent.Class,
		HostID:      intent.HostID,
		GuestID:     intent.GuestID,
		Source:      intent.Source,
		Disposition: d.Disposition,
		Allowed:     d.Allowed,
		Reason:      d.Reason,
	}
	if d.Verified != nil {
		rec.KeyID = d.Verified.Signer.KeyID
		rec.Nonce = d.Verified.Nonce
	}
	g.audit.Record(rec)
	g.logger.Info("gate decision",
		"class", intent.Class, "guest", intent.GuestID, "source", intent.Source,
		"disposition", d.Disposition, "allowed", d.Allowed, "reason", d.Reason)
}

// intentForAction builds the gate Intent for a benign reconcile action. The provenance
// is the zero value (no agent-internal destroy evidence) and the source is the
// desired-state delta — reconcile never fabricates scratch/same-txn provenance.
func intentForAction(hostID string, act Action) Intent {
	return Intent{
		Class:      classOfAction(act.Kind),
		HostID:     hostID,
		GuestID:    strconv.Itoa(act.VMID),
		VMID:       act.VMID,
		Provenance: Provenance{}, // benign actions need none; never hub-sourced
		Source:     SourceDesiredDelta,
	}
}

// noopAudit drops audit records (used when no sink is configured).
type noopAudit struct{}

func (noopAudit) Record(AuditRecord) {}

// SlogAudit is a minimal AuditSink that emits records to a logger. The durable,
// customer-visible audit log + its inclusion in the host-report (HostReport.AuditTail)
// is a later-slice concern; this keeps the signal flowing now without inventing that
// wire schema.
type SlogAudit struct{ Logger *slog.Logger }

// Record logs the audit entry at info level.
func (s SlogAudit) Record(rec AuditRecord) {
	if s.Logger == nil {
		return
	}
	s.Logger.Info("audit: gate decision",
		"class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source,
		"disposition", rec.Disposition, "allowed", rec.Allowed, "reason", rec.Reason,
		"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce), "durable_id", rec.DurableID)
}

// auditNonce shortens a nonce for the log (full nonce is high-cardinality; a prefix is
// enough to correlate without bloating logs).
func auditNonce(n string) string {
	if len(n) <= 8 {
		return n
	}
	return n[:8] + "…"
}
