// Package txn implements orca's transactional control-plane update
// mechanism (P10a, v0.11 milestone; REQ-075, REQ-079; gates C-09, C-23).
//
// The model is ArgoCD-style desired-state + lead-applier:
//
//   - RenderBundle marshals a desired-state object to JSON, computes a
//     content-addressed TxnID (T- + first 16 hex chars of SHA-256 of
//     the JSON), and generates apply.sh / verify.sh / rollback.sh
//     scripts plus a signed manifest (HMAC-SHA256 under the cluster
//     master key). The bundle is self-contained and reproducible: the
//     same desired-state + key always yields the same TxnID and the
//     same scripts.
//   - Stage SCPs the bundle to the lead peer's /run/orca/txns/<txn-id>/
//     directory using the sshpush transport (idempotent writes).
//   - Apply runs apply.sh on the lead (idempotent: re-running an
//     already-applied txn is a no-op), then verify.sh. On verify
//     failure it runs rollback.sh and returns an error. The
//     namespace-scoped vs cluster-wide distinction (C-23) is enforced
//     by the caller via ApplyOptions.Namespace and the lead-side
//     orca-pull.sh script.
//
// The package never logs key material. slog calls carry only metadata.
package txn

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"strings"
	"time"

	"git.cloudinit.dev/coreci/orca/internal/sshpush"
)

const (
	txnIDPrefix   = "T-"
	txnIDHexLen   = 16
	remoteTxnRoot = "/run/orca/txns"
)

const (
	fileDesiredState = "desired-state.json"
	fileApply        = "apply.sh"
	fileVerify       = "verify.sh"
	fileRollback     = "rollback.sh"
	fileManifest     = "manifest.json"
	fileManifestSig  = "manifest.sig"
	fileApplied      = ".applied"
)

type TxnID string

func (id TxnID) String() string { return string(id) }

type ManifestEntry struct {
	Name   string `json:"name"`
	SHA256 string `json:"sha256"`
}

type Manifest struct {
	TxnID     TxnID           `json:"txn_id"`
	Timestamp string          `json:"timestamp"`
	Files     []ManifestEntry `json:"files"`
}

type Bundle struct {
	ID             TxnID
	DesiredState   json.RawMessage
	ApplyScript    []byte
	VerifyScript   []byte
	RollbackScript []byte
	Manifest       []byte
	ManifestSig    []byte
}

type ApplyOptions struct {
	Force           bool
	AcknowledgeRisk bool
	Yes             bool
	Namespace       string
	Timeout         time.Duration
}

// Transport is the SSH-push surface the txn package needs: writing
// files idempotently and running remote commands. *sshpush.Transport
// satisfies it; tests substitute a mock to assert staging and apply
// behavior without a real SSH server (same pattern as
// internal/emitter.AtomicWriter).
type Transport interface {
	WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error)
	Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}

var (
	ErrSignatureMismatch        = errors.New("txn: manifest signature mismatch")
	ErrNotApplied               = errors.New("txn: not applied")
	ErrAlreadyApplied           = errors.New("txn: already applied")
	ErrClusterWideRequiresForce = errors.New("cluster-wide txn requires --force")
	ErrClusterWideRequiresAck   = errors.New("cluster-wide --force requires --i-understand-the-risk (or --yes)")
)

func RenderBundle(desiredState any, masterKey []byte) (*Bundle, error) {
	if len(masterKey) == 0 {
		return nil, fmt.Errorf("txn: master key is empty")
	}
	data, err := json.MarshalIndent(desiredState, "", "  ")
	if err != nil {
		return nil, fmt.Errorf("txn: marshal desired state: %w", err)
	}
	var norm any
	if err := json.Unmarshal(data, &norm); err != nil {
		return nil, fmt.Errorf("txn: normalize desired state: %w", err)
	}
	canonical, err := json.Marshal(norm)
	if err != nil {
		return nil, fmt.Errorf("txn: re-marshal desired state: %w", err)
	}

	id, err := computeTxnID(canonical)
	if err != nil {
		return nil, err
	}
	ts := time.Now().UTC().Format(time.RFC3339Nano)

	apply := renderApplyScript(id)
	verify := renderVerifyScript(id)
	rollback := renderRollbackScript(id)

	manifest, err := buildManifest(id, ts, canonical, apply, verify, rollback)
	if err != nil {
		return nil, err
	}
	sig := signManifest(manifest, masterKey)

	slog.Info("txn bundle rendered", "txn_id", id, "desired_bytes", len(canonical))
	return &Bundle{
		ID:             id,
		DesiredState:   canonical,
		ApplyScript:    apply,
		VerifyScript:   verify,
		RollbackScript: rollback,
		Manifest:       manifest,
		ManifestSig:    sig,
	}, nil
}

func computeTxnID(data []byte) (TxnID, error) {
	sum := sha256.Sum256(data)
	h := hex.EncodeToString(sum[:])
	if len(h) < txnIDHexLen {
		return "", fmt.Errorf("txn: sha256 hex too short")
	}
	return TxnID(txnIDPrefix + h[:txnIDHexLen]), nil
}

func sha256Hex(b []byte) string {
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}

func buildManifest(id TxnID, ts string, desired, apply, verify, rollback []byte) ([]byte, error) {
	m := Manifest{
		TxnID:     id,
		Timestamp: ts,
		Files: []ManifestEntry{
			{Name: fileDesiredState, SHA256: sha256Hex(desired)},
			{Name: fileApply, SHA256: sha256Hex(apply)},
			{Name: fileVerify, SHA256: sha256Hex(verify)},
			{Name: fileRollback, SHA256: sha256Hex(rollback)},
		},
	}
	out, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return nil, fmt.Errorf("txn: marshal manifest: %w", err)
	}
	return out, nil
}

func signManifest(manifest, masterKey []byte) []byte {
	mac := hmac.New(sha256.New, masterKey)
	mac.Write(manifest)
	return []byte(hex.EncodeToString(mac.Sum(nil)))
}

func VerifyManifestSignature(manifest, sigHex, masterKey []byte) error {
	if len(masterKey) == 0 {
		return fmt.Errorf("txn: master key is empty")
	}
	mac := hmac.New(sha256.New, masterKey)
	mac.Write(manifest)
	got := mac.Sum(nil)
	want, err := hex.DecodeString(string(sigHex))
	if err != nil {
		return fmt.Errorf("txn: decode manifest signature: %w", err)
	}
	if !hmac.Equal(got, want) {
		return ErrSignatureMismatch
	}
	return nil
}

func remoteTxnDir(id TxnID) string {
	return remoteTxnRoot + "/" + string(id)
}

func Stage(bundle *Bundle, leadPeer string, transport Transport) error {
	if bundle == nil {
		return fmt.Errorf("txn: nil bundle")
	}
	if leadPeer == "" {
		return fmt.Errorf("txn: lead peer is empty")
	}
	if transport == nil {
		return fmt.Errorf("txn: nil transport")
	}
	dir := remoteTxnDir(bundle.ID)
	ctx := context.Background()

	files := []struct {
		name    string
		content []byte
		mode    os.FileMode
	}{
		{fileDesiredState, bundle.DesiredState, 0o644},
		{fileApply, bundle.ApplyScript, 0o755},
		{fileVerify, bundle.VerifyScript, 0o755},
		{fileRollback, bundle.RollbackScript, 0o755},
		{fileManifest, bundle.Manifest, 0o644},
		{fileManifestSig, bundle.ManifestSig, 0o644},
	}
	for _, f := range files {
		path := dir + "/" + f.name
		if _, err := transport.WriteFileIdempotent(ctx, leadPeer, path, f.content, f.mode); err != nil {
			return fmt.Errorf("txn: stage %s: %w", f.name, err)
		}
	}
	slog.Info("txn staged", "txn_id", bundle.ID, "peer", leadPeer, "dir", dir)
	return nil
}

func Apply(ctx context.Context, txnID TxnID, leadPeer string, transport Transport, opts ApplyOptions) error {
	if transport == nil {
		return fmt.Errorf("txn: nil transport")
	}
	if leadPeer == "" {
		return fmt.Errorf("txn: lead peer is empty")
	}
	dir := remoteTxnDir(txnID)
	pull := dir + "/orca-pull.sh"

	if opts.Timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
		defer cancel()
	}

	cmd := fmt.Sprintf("bash %s --txn-dir %s", pull, shellQuote(dir))
	if opts.Namespace != "" {
		cmd += fmt.Sprintf(" --namespace %s", shellQuote(opts.Namespace))
	} else {
		if !opts.Force {
			return fmt.Errorf("txn: cluster-wide txn requires --force (C-23): %w", ErrClusterWideRequiresForce)
		}
		if !opts.AcknowledgeRisk && !opts.Yes {
			return fmt.Errorf("txn: cluster-wide --force requires --i-understand-the-risk (or --yes): %w", ErrClusterWideRequiresAck)
		}
		cmd += " --force"
		if opts.AcknowledgeRisk {
			cmd += " --i-understand-the-risk"
		}
		if opts.Yes {
			cmd += " --yes"
		}
	}

	out, err := transport.Exec(ctx, leadPeer, cmd)
	if err != nil {
		if isExitCode(err, 5) {
			slog.Info("txn already applied (no-op)", "txn_id", txnID, "peer", leadPeer)
			return ErrAlreadyApplied
		}
		return fmt.Errorf("txn: apply %s on %s: %w (output: %s)", txnID, leadPeer, err, string(out))
	}
	slog.Info("txn applied", "txn_id", txnID, "peer", leadPeer, "output", string(out))
	return nil
}

func isExitCode(err error, code int) bool {
	if err == nil {
		return false
	}
	return errors.Is(err, sshpush.ErrPermanent) && strings.Contains(err.Error(), fmt.Sprintf("exit %d", code))
}

func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func renderApplyScript(id TxnID) []byte {
	return []byte(`#!/usr/bin/env bash
# apply.sh — orca txn ` + string(id) + ` (auto-generated; do not edit).
# Idempotently writes the desired state to disk. Re-running after a
# successful apply is a no-op (checks .applied marker).
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE="$DIR/` + fileDesiredState + `"
MARKER="$DIR/` + fileApplied + `"

if [ -f "$MARKER" ]; then
	echo "already-applied"
	exit 0
fi

if command -v python3 >/dev/null 2>&1; then
	python3 - "$STATE" <<'PYEOF'
import json, os, sys
state_path = sys.argv[1]
with open(state_path) as f:
    artifacts = json.load(f)
if isinstance(artifacts, dict):
    artifacts = [artifacts]

# REQ-121/F5: path allowlist. Only orca-managed paths may be written.
# This prevents a compromised manifest from overwriting arbitrary
# system files (e.g. /etc/shadow, /root/.ssh/authorized_keys).
ALLOWED_PREFIXES = (
    "/etc/orca/",
    "/etc/traefik/orca",
    "/etc/traefik/dynamic/orca",
    "/etc/systemd/system/orca-",
    "/etc/nftables.d/orca",
    "/etc/syncthing/orca",
)
# Resolve symlinks + normalize to catch ../ traversal attempts.
def path_allowed(p):
    if not p:
        return False
    # Reject any path containing .. (path traversal).
    if ".." in p.split("/"):
        return False
    # Reject paths that are not absolute (relative could land anywhere).
    if not p.startswith("/"):
        return False
    norm = os.path.normpath(p)
    for prefix in ALLOWED_PREFIXES:
        if norm == prefix or norm.startswith(prefix):
            return True
    return False

for a in artifacts:
    path = a.get("path")
    if not path:
        continue
    if not path_allowed(path):
        sys.stderr.write("apply: refusing to write disallowed path: %s\n" % path)
        sys.exit(7)
    content = a.get("content", "")
    mode = a.get("mode", "0644")
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as fh:
        fh.write(content)
    try:
        m = int(mode, 8)
        os.chmod(path, m)
    except (ValueError, TypeError):
        pass
PYEOF
fi

touch "$MARKER"
echo "applied"
`)
}

func renderVerifyScript(id TxnID) []byte {
	return []byte(`#!/usr/bin/env bash
# verify.sh — orca txn ` + string(id) + ` (auto-generated; do not edit).
# Verifies the applied state matches desired-state.json.
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE="$DIR/` + fileDesiredState + `"

if [ ! -f "$STATE" ]; then
	echo "verify: missing desired-state" >&2
	exit 1
fi

if command -v python3 >/dev/null 2>&1; then
	python3 - "$STATE" <<'PYEOF'
import json, os, sys
state_path = sys.argv[1]
with open(state_path) as f:
    artifacts = json.load(f)
if isinstance(artifacts, dict):
    artifacts = [artifacts]
ok = True
for a in artifacts:
    path = a.get("path")
    if not path:
        continue
    want = a.get("content", "")
    if not os.path.exists(path):
        print("verify: missing %s" % path, file=sys.stderr)
        ok = False
        continue
    with open(path) as fh:
        got = fh.read()
    if got != want:
        print("verify: mismatch %s" % path, file=sys.stderr)
        ok = False
sys.exit(0 if ok else 1)
PYEOF
else
	echo "verify: python3 missing, cannot verify" >&2
	exit 1
fi
echo "verified"
`)
}

func renderRollbackScript(id TxnID) []byte {
	return []byte(`#!/usr/bin/env bash
# rollback.sh — orca txn ` + string(id) + ` (auto-generated; do not edit).
# Reverts the apply by removing the .applied marker and the written files.
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE="$DIR/` + fileDesiredState + `"
MARKER="$DIR/` + fileApplied + `"

rm -f "$MARKER"

if command -v python3 >/dev/null 2>&1 && [ -f "$STATE" ]; then
	python3 - "$STATE" <<'PYEOF'
import json, os, sys
state_path = sys.argv[1]
with open(state_path) as f:
    artifacts = json.load(f)
if isinstance(artifacts, dict):
    artifacts = [artifacts]
for a in artifacts:
    path = a.get("path")
    if not path:
        continue
    if os.path.exists(path):
        try:
            os.remove(path)
        except OSError:
            pass
PYEOF
fi
echo "rolled-back"
`)
}

var _ Transport = (*sshpush.Transport)(nil)
