package ipc

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"testing"
)

// The wire must carry the PRF secret, not the credential public key. This is
// the field rename that fixes Vikunja #14: a v1 deployment sent "public_key",
// and the value it sent was in passkeys.json next to the wrapped blob.
func TestUnlockWireCarriesSecret(t *testing.T) {
	secret := bytes.Repeat([]byte{7}, 32)
	for _, p := range []any{unlockReq{Secret: secret}, storeEncryptionKeyReq{Secret: secret}} {
		b, err := json.Marshal(p)
		if err != nil {
			t.Fatalf("marshal %T: %v", p, err)
		}
		var m map[string]any
		if err := json.Unmarshal(b, &m); err != nil {
			t.Fatalf("unmarshal %T: %v", p, err)
		}
		if _, ok := m["secret"]; !ok {
			t.Errorf("%T has no \"secret\" field: %s", p, b)
		}
		if _, ok := m["public_key"]; ok {
			t.Errorf("%T still sends \"public_key\": %s", p, b)
		}
	}
}

// The secret must reach the daemon hook byte-for-byte through the socket.
func TestUnlockDeliversSecretToHook(t *testing.T) {
	_, srv, cli, _ := newServerWithStore(t)

	secret := make([]byte, 32)
	for i := range secret {
		secret[i] = byte(i + 1)
	}
	var gotUnlock, gotWrap []byte
	var gotExplicit bool
	srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil }
	srv.WrapKeyFn = func(_ context.Context, s []byte, explicit bool) error {
		gotWrap = bytes.Clone(s)
		gotExplicit = explicit
		return nil
	}

	ctx := context.Background()
	if err := cli.Unlock(ctx, secret); err != nil {
		t.Fatalf("Unlock: %v", err)
	}
	if !bytes.Equal(gotUnlock, secret) {
		t.Errorf("UnlockFn got %x, want %x", gotUnlock, secret)
	}
	if err := cli.StoreEncryptionKey(ctx, secret, true); err != nil {
		t.Fatalf("StoreEncryptionKey: %v", err)
	}
	if !bytes.Equal(gotWrap, secret) {
		t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret)
	}
	// The explicit flag rides the same request. Without it the daemon cannot
	// tell "he asked for the cold-start key to be rewritten" from "a passkey
	// was asserted", and rewrites the blob on every step-up.
	if !gotExplicit {
		t.Error("WrapKeyFn got explicit=false, want the flag to cross the wire")
	}
	if err := cli.StoreEncryptionKey(ctx, secret, false); err != nil {
		t.Fatalf("StoreEncryptionKey: %v", err)
	}
	if gotExplicit {
		t.Error("WrapKeyFn got explicit=true for an implicit wrap")
	}
}

// A refusal from the daemon hook — a wrong passkey, or no prior assertion —
// must surface to the caller as an error, never be swallowed into success.
func TestUnlockPropagatesRefusal(t *testing.T) {
	_, srv, cli, _ := newServerWithStore(t)
	srv.UnlockFn = func(context.Context, []byte) error {
		return errors.New("unlock: no verified passkey assertion (assert first)")
	}
	if err := cli.Unlock(context.Background(), bytes.Repeat([]byte{9}, 32)); err == nil {
		t.Fatal("a refused unlock reported success")
	}
}

// Without the hooks wired — the normal, unencrypted deployment — both methods
// answer ErrUnknownMethod rather than pretending to have done something.
func TestUnlockUnwiredIsUnknownMethod(t *testing.T) {
	_, _, cli, _ := newServerWithStore(t)
	ctx := context.Background()
	if err := cli.Unlock(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
		t.Error("Unlock succeeded with no UnlockFn wired")
	}
	if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32), false); err == nil {
		t.Error("StoreEncryptionKey succeeded with no WrapKeyFn wired")
	}
}

// Locked mode: Server.Check is the whole authorization surface, and it must
// default-deny everything except the two methods the unlock flow needs.
func TestLockedCheckDefaultDenies(t *testing.T) {
	_, srv, cli, _ := newServerWithStore(t)

	locked := errors.New("locked")
	srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
		switch m {
		case MethodAssertStepUp, MethodUnlock:
			return nil
		default:
			return locked
		}
	}
	unlocked := false
	srv.UnlockFn = func(context.Context, []byte) error { unlocked = true; return nil }
	srv.StepUp = func(context.Context) error { return nil }
	srv.WrapKeyFn = func(context.Context, []byte, bool) error { return nil }

	ctx := context.Background()
	// A store method must be refused while locked.
	if _, err := cli.RecentNotes(ctx, 5); err == nil {
		t.Error("a store read went through while locked")
	}
	// Key wrapping is NOT on the allowlist: a locked daemon has no key to wrap.
	if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32), false); err == nil {
		t.Error("StoreEncryptionKey was allowed while locked")
	}
	// The unlock flow itself must still work.
	if err := cli.AssertStepUp(ctx); err != nil {
		t.Errorf("AssertStepUp refused while locked: %v", err)
	}
	if err := cli.Unlock(ctx, bytes.Repeat([]byte{3}, 32)); err != nil {
		t.Errorf("Unlock refused while locked: %v", err)
	}
	if !unlocked {
		t.Error("UnlockFn never ran")
	}
}

// A locked daemon has to be able to say it is alive. Every CoreAPI method is
// refused before unlock, so a health check built on one of those cannot tell a
// daemon waiting for a passkey apart from a daemon that failed to start. That
// is what turned a good update into the manual-recovery case in
// internal/update. MethodPing reaches no store, so it answers either way.
func TestPingAnswersWhileLocked(t *testing.T) {
	_, srv, cli, _ := newServerWithStore(t)
	srv.LockedFn = func() bool { return true }
	locked := errors.New("daemon locked")
	srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
		switch m {
		case MethodAssertStepUp, MethodUnlock, MethodPing:
			return nil
		default:
			return locked
		}
	}
	ctx := context.Background()
	p, err := cli.Ping(ctx)
	if err != nil {
		t.Fatalf("Ping while locked: %v", err)
	}
	if !p.Alive || !p.Locked {
		t.Errorf("Ping = %+v; want alive and locked", p)
	}
	// And the read it replaces is still refused, which is the whole point.
	if _, err := cli.Presence(ctx); err == nil {
		t.Error("Presence answered while locked")
	}

	srv.LockedFn = func() bool { return false }
	if p, err := cli.Ping(ctx); err != nil || p.Locked {
		t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err)
	}
}
