package catalogapp

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
	"sort"
)

// ProjectionDigest is Catalog's checksum over a complete, canonical per-org
// Runtime security projection. It is a publication receipt, not App identity.
func ProjectionDigest(projection RuntimeSecurityProjection) (string, error) {
	if projection.OrgID == "" || projection.RuntimeAudience == "" || projection.SecurityEpoch == 0 {
		return "", fmt.Errorf("complete Runtime security projection is required")
	}
	bindings := append([]RuntimeSecurityBinding(nil), projection.Bindings...)
	for i := range bindings {
		if bindings[i].OrgID != projection.OrgID || bindings[i].CallerWorkloadID == "" || bindings[i].AppInstallationID == "" {
			return "", fmt.Errorf("projection binding[%d] is incomplete", i)
		}
		capabilities, err := canonicalStrings("projection capabilities", bindings[i].Capabilities, false)
		if err != nil {
			return "", err
		}
		bindings[i].Capabilities = capabilities
	}
	sort.Slice(bindings, func(i, j int) bool {
		return projectionBindingKey(bindings[i]) < projectionBindingKey(bindings[j])
	})
	for i := 1; i < len(bindings); i++ {
		if bindings[i].OrgID == bindings[i-1].OrgID && bindings[i].AppInstallationID == bindings[i-1].AppInstallationID && bindings[i].CallerWorkloadID == bindings[i-1].CallerWorkloadID {
			return "", fmt.Errorf("projection contains duplicate binding")
		}
	}
	type canonicalBinding struct {
		CallerWorkloadID  string   `json:"caller_workload_id"`
		OrgID             string   `json:"org_id"`
		AppInstallationID string   `json:"app_installation_id"`
		Capabilities      []string `json:"capabilities"`
		Active            bool     `json:"active"`
	}
	canonicalBindings := make([]canonicalBinding, len(bindings))
	for i, binding := range bindings {
		canonicalBindings[i] = canonicalBinding{binding.CallerWorkloadID, binding.OrgID,
			binding.AppInstallationID, binding.Capabilities, binding.Active}
	}
	canonical := struct {
		SchemaVersion   string             `json:"schema_version"`
		OrgID           string             `json:"org_id"`
		RuntimeAudience string             `json:"runtime_audience"`
		SecurityEpoch   uint64             `json:"security_epoch"`
		Bindings        []canonicalBinding `json:"bindings"`
	}{"nexuscrm.runtime-security-projection/v1", projection.OrgID, projection.RuntimeAudience, projection.SecurityEpoch, canonicalBindings}
	encoded, err := json.Marshal(canonical)
	if err != nil {
		return "", err
	}
	return digestParts("nexuscrm.catalog-runtime-projection.v1", string(encoded)), nil
}

func (a *Authority) buildProjection(ctx context.Context, query sqlQueryer, operationID, installationID, orgID string, kind OperationKind, securityEpoch uint64) (RuntimeSecurityProjection, error) {
	var audience string
	if err := query.QueryRowContext(ctx, `SELECT RuntimeAudience FROM _System_CatalogOrgState WHERE OrgId = ?`, orgID).Scan(&audience); err != nil {
		return RuntimeSecurityProjection{}, err
	}
	rows, err := query.QueryContext(ctx, `SELECT AppInstallationId, ActiveReleaseDigest,
ActiveAuthorizedWorkloads, ActiveGrantedCapabilities, CandidateAuthorizedWorkloads, CandidateGrantedCapabilities, ActiveOperationId
FROM _System_CatalogAppInstallation
WHERE OrgId = ? AND LiveAppKey IS NOT NULL AND (ActiveReleaseDigest IS NOT NULL OR (AppInstallationId = ? AND ActiveOperationId = ?))
ORDER BY AppInstallationId`, orgID, installationID, operationID)
	if err != nil {
		return RuntimeSecurityProjection{}, err
	}
	defer rows.Close()
	projection := RuntimeSecurityProjection{OrgID: orgID, RuntimeAudience: audience, SecurityEpoch: securityEpoch}
	for rows.Next() {
		var id string
		var activeRelease, activeOperation sql.NullString
		var activeWorkloadsJSON, activeCapabilitiesJSON, candidateWorkloadsJSON, candidateCapabilitiesJSON []byte
		if err := rows.Scan(&id, &activeRelease, &activeWorkloadsJSON, &activeCapabilitiesJSON,
			&candidateWorkloadsJSON, &candidateCapabilitiesJSON, &activeOperation); err != nil {
			return RuntimeSecurityProjection{}, err
		}
		if id == installationID && kind == OperationRemove {
			continue
		}
		workloadsJSON, capabilitiesJSON := activeWorkloadsJSON, activeCapabilitiesJSON
		if id == installationID && activeOperation.String == operationID {
			workloadsJSON, capabilitiesJSON = candidateWorkloadsJSON, candidateCapabilitiesJSON
		} else if !activeRelease.Valid {
			continue
		}
		var workloads, capabilities []string
		if err := json.Unmarshal(workloadsJSON, &workloads); err != nil {
			return RuntimeSecurityProjection{}, fmt.Errorf("decode Catalog workloads: %w", err)
		}
		if err := json.Unmarshal(capabilitiesJSON, &capabilities); err != nil {
			return RuntimeSecurityProjection{}, fmt.Errorf("decode Catalog capabilities: %w", err)
		}
		for _, workload := range workloads {
			projection.Bindings = append(projection.Bindings, RuntimeSecurityBinding{
				CallerWorkloadID: workload, OrgID: orgID, AppInstallationID: id,
				Capabilities: append([]string(nil), capabilities...), Active: true,
			})
		}
	}
	if err := rows.Err(); err != nil {
		return RuntimeSecurityProjection{}, err
	}
	sort.Slice(projection.Bindings, func(i, j int) bool {
		return projectionBindingKey(projection.Bindings[i]) < projectionBindingKey(projection.Bindings[j])
	})
	return projection, nil
}

func projectionBindingKey(binding RuntimeSecurityBinding) string {
	return binding.OrgID + "\x00" + binding.AppInstallationID + "\x00" + binding.CallerWorkloadID
}

type sqlQueryer interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
	QueryRowContext(context.Context, string, ...any) *sql.Row
}

func readOperationOrgID(
	ctx context.Context,
	query sqlQueryer,
	operationID string,
) (string, error) {
	var orgID string
	err := query.QueryRowContext(
		ctx,
		`SELECT OrgId FROM _System_CatalogAppOperation WHERE OperationId = ?`,
		operationID,
	).Scan(&orgID)
	if errors.Is(err, sql.ErrNoRows) {
		return "", ErrNotFound
	}
	if err != nil {
		return "", err
	}
	return orgID, nil
}

func (a *Authority) PublishRuntimeSecurityProjection(ctx context.Context, operationID string, writer RuntimeProjectionWriter) error {
	if writer == nil || isNilRuntimeProjectionWriter(writer) {
		return fmt.Errorf("Runtime projection writer is required")
	}
	operation, err := a.readOperation(ctx, a.db, operationID, false)
	if err != nil {
		return err
	}
	if operation.Step == "PROJECTION_ACKNOWLEDGED" {
		if operation.Kind != OperationRemove {
			if _, err := verifyServingArtifactCommitment(ctx, a.db, operation.OperationID); err != nil {
				return fmt.Errorf("verify committed serving selection on projection retry: %w", err)
			}
		}
		if operation.AcknowledgedSecurityEpoch == operation.TargetSecurityEpoch &&
			operation.AcknowledgedProjectionDigest == operation.ExpectedProjectionDigest {
			return nil
		}
		return fmt.Errorf("%w: projection acknowledgement does not match the committed candidate", ErrConflict)
	}
	readyStep := OperationStepServingArtifactsCommitted
	if operation.Kind == OperationRemove {
		readyStep = "CANDIDATE_COMMITTED"
	}
	if operation.Step != readyStep || operation.State == StateFailed || operation.State == StateRemoved {
		return ErrNotReady
	}
	if operation.Kind != OperationRemove {
		if _, err := verifyServingArtifactCommitment(ctx, a.db, operation.OperationID); err != nil {
			return fmt.Errorf("verify committed serving selection before projection: %w", err)
		}
	}
	projection, err := a.buildProjection(ctx, a.db, operation.OperationID, operation.AppInstallationID, operation.OrgID, operation.Kind, operation.TargetSecurityEpoch)
	if err != nil {
		return err
	}
	digest, err := ProjectionDigest(projection)
	if err != nil {
		return err
	}
	if digest != operation.ExpectedProjectionDigest {
		return fmt.Errorf("%w: committed projection checksum changed", ErrConflict)
	}
	if err := writer.ActivateProjection(ctx, operation.TargetSecurityEpoch-1, projection); err != nil {
		return err
	}
	tx, err := a.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer rollback(tx)
	locked, err := a.readOperation(ctx, tx, operationID, true)
	if err != nil {
		return err
	}
	if locked.Step == "PROJECTION_ACKNOWLEDGED" && locked.AcknowledgedSecurityEpoch == operation.TargetSecurityEpoch && locked.AcknowledgedProjectionDigest == digest {
		return tx.Commit()
	}
	if locked.Step != readyStep || locked.State != transitionState(locked.Kind) ||
		locked.ExpectedProjectionDigest != digest || locked.TargetSecurityEpoch != operation.TargetSecurityEpoch {
		return ErrConflict
	}
	result, err := tx.ExecContext(ctx, `UPDATE _System_CatalogAppOperation
SET Step = 'PROJECTION_ACKNOWLEDGED', AcknowledgedSecurityEpoch = ?, AcknowledgedProjectionDigest = ?, LastModifiedDate = CURRENT_TIMESTAMP(6)
WHERE OperationId = ? AND Step = ? AND State = ? AND ExpectedSecurityEpoch = ? AND ExpectedProjectionDigest = ?`,
		epochText(operation.TargetSecurityEpoch), digest, operationID, readyStep, transitionState(locked.Kind), epochText(operation.TargetSecurityEpoch), digest)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil || affected != 1 {
		return ErrConflict
	}
	if err := appendNextJournal(ctx, tx, operationID, "PROJECTION_ACKNOWLEDGED", string(locked.State), digest); err != nil {
		return err
	}
	return tx.Commit()
}

func isNilRuntimeProjectionWriter(writer RuntimeProjectionWriter) bool {
	value := reflect.ValueOf(writer)
	switch value.Kind() {
	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
		return value.IsNil()
	default:
		return false
	}
}

func (a *Authority) readOperation(ctx context.Context, query sqlQueryer, operationID string, forUpdate bool) (Operation, error) {
	suffix := ""
	if forUpdate {
		suffix = " FOR UPDATE"
	}
	var operation Operation
	var rawExpectedCatalog, rawExpectedSecurity []byte
	var rawAckSecurity sql.NullString
	var ackDigest sql.NullString
	err := query.QueryRowContext(ctx, `SELECT OperationId, Kind, AppInstallationId, OrgId, AppId, State, Step,
COALESCE(TargetReleaseDigest, ''), ExpectedCatalogRevision, ExpectedSecurityEpoch, ExpectedProjectionDigest,
AcknowledgedSecurityEpoch, AcknowledgedProjectionDigest, COALESCE(LastError, '')
FROM _System_CatalogAppOperation WHERE OperationId = ?`+suffix, operationID).Scan(
		&operation.OperationID, &operation.Kind, &operation.AppInstallationID, &operation.OrgID, &operation.AppID,
		&operation.State, &operation.Step, &operation.TargetAppReleaseDigest, &rawExpectedCatalog,
		&rawExpectedSecurity, &operation.ExpectedProjectionDigest, &rawAckSecurity, &ackDigest, &operation.LastErrorDigest)
	if errors.Is(err, sql.ErrNoRows) {
		return Operation{}, ErrNotFound
	}
	if err != nil {
		return Operation{}, err
	}
	operation.TargetCatalogRevision, err = parseEpochAllowZero(rawExpectedCatalog)
	if err != nil {
		return Operation{}, err
	}
	operation.TargetSecurityEpoch, err = parseEpoch(rawExpectedSecurity)
	if err != nil {
		return Operation{}, err
	}
	if rawAckSecurity.Valid {
		operation.AcknowledgedSecurityEpoch, err = parseEpoch([]byte(rawAckSecurity.String))
		if err != nil {
			return Operation{}, err
		}
	}
	if ackDigest.Valid {
		operation.AcknowledgedProjectionDigest = ackDigest.String
	}
	return operation, nil
}

func parseEpochAllowZero(raw []byte) (uint64, error) {
	if string(raw) == "0" {
		return 0, nil
	}
	return parseEpoch(raw)
}

func appendJournal(ctx context.Context, tx *sql.Tx, operationID string, sequence uint64, step, state, evidence string) error {
	if evidence == "" {
		evidence = digestParts(operationID, step, state)
	}
	_, err := tx.ExecContext(ctx, `INSERT INTO _System_CatalogAppOperationJournal
(OperationId, SequenceNumber, Step, State, EvidenceDigest, CreatedDate)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP(6))`, operationID, epochText(sequence), step, state, evidence)
	if isDuplicate(err) {
		var existingStep, existingState, existingEvidence string
		queryErr := tx.QueryRowContext(ctx, `SELECT Step, State, EvidenceDigest FROM _System_CatalogAppOperationJournal
WHERE OperationId = ? AND SequenceNumber = ? FOR UPDATE`, operationID, epochText(sequence)).Scan(&existingStep, &existingState, &existingEvidence)
		if queryErr == nil && existingStep == step && existingState == state && existingEvidence == evidence {
			return nil
		}
		return ErrConflict
	}
	return err
}

func appendNextJournal(ctx context.Context, tx *sql.Tx, operationID, step, state, evidence string) error {
	var raw []byte
	err := tx.QueryRowContext(ctx, `SELECT SequenceNumber FROM _System_CatalogAppOperationJournal
WHERE OperationId = ? ORDER BY SequenceNumber DESC LIMIT 1 FOR UPDATE`, operationID).Scan(&raw)
	if errors.Is(err, sql.ErrNoRows) {
		return appendJournal(ctx, tx, operationID, 1, step, state, evidence)
	}
	if err != nil {
		return err
	}
	sequence, err := parseEpoch(raw)
	if err != nil || sequence == ^uint64(0) {
		return ErrConflict
	}
	return appendJournal(ctx, tx, operationID, sequence+1, step, state, evidence)
}
