import { describe, test, expect } from 'bun:test';
import {
  emptyCanonicalProfile,
  parseCanonicalProfile,
  serializeCanonicalProfile,
  upsertHost,
  upsertClient,
  removeClient,
  setTrustedApprover,
  isTrustedApprover,
  findClientByFingerprint,
  findClientByAddress,
  buildApprovalRequest,
  signProxyApproval,
  verifyProxyApproval,
  canonicalApprovalSigningBytes,
  DEFAULT_APPROVAL_DOMAIN,
} from '../src/index.js';
import type {
  CanonicalProfileBlob,
  ProfileClientEntry,
  ProfileHostEntry,
  ApprovalRequestFrame,
} from '../src/index.js';
import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { ValidationError } from '@shade/core';

const crypto = new SubtleCryptoProvider();

function randBytes(n: number): Uint8Array {
  const buf = new Uint8Array(n);
  globalThis.crypto.getRandomValues(buf);
  return buf;
}

function bytesToHex(bytes: Uint8Array): string {
  let s = '';
  for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0');
  return s;
}

function makeClient(opts: {
  name: string;
  trusted?: boolean;
}): { entry: ProfileClientEntry; signingSeed: Uint8Array } {
  const signingSeed = randBytes(32);
  const pubkey = ed25519PublicKeyFromSeed(signingSeed);
  const fingerprint = `fp-${opts.name}-${bytesToHex(pubkey).slice(0, 8)}`;
  const entry: ProfileClientEntry = {
    address: `device:${opts.name}`,
    identityPublicKey: bytesToHex(pubkey),
    identityFingerprint: fingerprint,
    name: opts.name,
    kind: 'mobile',
    addedAt: 1_700_000_000_000,
  };
  if (opts.trusted) entry.trustedApprover = true;
  return { entry, signingSeed };
}

function makeHost(): ProfileHostEntry {
  return {
    address: 'device:host-server',
    name: 'Server',
    kind: 'server',
    addedAt: 1_700_000_000_000,
  };
}

describe('Canonical profile schema', () => {
  test('emptyCanonicalProfile is well-formed and round-trips', () => {
    const blob = emptyCanonicalProfile(123);
    expect(blob.version).toBe(1);
    expect(blob.hosts).toEqual([]);
    expect(blob.clients).toEqual([]);
    expect(blob.trustedApproverFingerprints).toEqual([]);
    expect(blob.updatedAt).toBe(123);

    const bytes = serializeCanonicalProfile(blob);
    const parsed = parseCanonicalProfile(bytes);
    expect(parsed).toEqual(blob);
  });

  test('upsertHost replaces by address and bumps updatedAt', () => {
    let blob = emptyCanonicalProfile(0);
    const host = makeHost();
    blob = upsertHost(blob, host, 100);
    expect(blob.hosts).toEqual([host]);
    expect(blob.updatedAt).toBe(100);

    const renamed = { ...host, name: 'Server (renamed)' };
    blob = upsertHost(blob, renamed, 200);
    expect(blob.hosts).toHaveLength(1);
    expect(blob.hosts[0]!.name).toBe('Server (renamed)');
    expect(blob.updatedAt).toBe(200);
  });

  test('upsertClient denormalizes trustedApproverFingerprints', () => {
    let blob = emptyCanonicalProfile(0);
    const a = makeClient({ name: 'phone-a', trusted: true });
    const b = makeClient({ name: 'phone-b', trusted: false });
    blob = upsertClient(blob, a.entry, 100);
    blob = upsertClient(blob, b.entry, 200);

    expect(blob.clients).toHaveLength(2);
    expect(blob.trustedApproverFingerprints).toEqual([a.entry.identityFingerprint]);
    expect(isTrustedApprover(blob, a.entry.identityFingerprint)).toBe(true);
    expect(isTrustedApprover(blob, b.entry.identityFingerprint)).toBe(false);
  });

  test('setTrustedApprover toggles the flag and the denormalized list', () => {
    let blob = emptyCanonicalProfile(0);
    const c = makeClient({ name: 'phone', trusted: false });
    blob = upsertClient(blob, c.entry);
    expect(blob.trustedApproverFingerprints).toEqual([]);

    blob = setTrustedApprover(blob, c.entry.identityFingerprint, true, 100);
    expect(blob.trustedApproverFingerprints).toEqual([c.entry.identityFingerprint]);
    expect(blob.clients[0]!.trustedApprover).toBe(true);
    expect(blob.updatedAt).toBe(100);

    blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 200);
    expect(blob.trustedApproverFingerprints).toEqual([]);
    expect(blob.clients[0]!.trustedApprover).toBeUndefined();
    expect(blob.updatedAt).toBe(200);

    // No-op toggle to existing state returns the same blob.
    const before = blob;
    blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 999);
    expect(blob).toBe(before);
  });

  test('removeClient cleans up trustedApproverFingerprints', () => {
    let blob = emptyCanonicalProfile(0);
    const c = makeClient({ name: 'phone', trusted: true });
    blob = upsertClient(blob, c.entry);
    expect(blob.trustedApproverFingerprints).toHaveLength(1);

    blob = removeClient(blob, c.entry.identityFingerprint);
    expect(blob.clients).toEqual([]);
    expect(blob.trustedApproverFingerprints).toEqual([]);
  });

  test('findClientByFingerprint and findClientByAddress', () => {
    let blob = emptyCanonicalProfile(0);
    const c = makeClient({ name: 'phone' });
    blob = upsertClient(blob, c.entry);

    expect(findClientByFingerprint(blob, c.entry.identityFingerprint)?.address).toBe(
      c.entry.address,
    );
    expect(findClientByAddress(blob, c.entry.address)?.identityFingerprint).toBe(
      c.entry.identityFingerprint,
    );
    expect(findClientByFingerprint(blob, 'unknown')).toBeNull();
    expect(findClientByAddress(blob, 'unknown')).toBeNull();
  });

  test('parseCanonicalProfile rejects malformed input', () => {
    expect(() => parseCanonicalProfile('not json')).toThrow(ValidationError);
    expect(() => parseCanonicalProfile('[]')).toThrow(ValidationError);
    expect(() => parseCanonicalProfile('{"version":2}')).toThrow(ValidationError);
    expect(() =>
      parseCanonicalProfile(
        JSON.stringify({
          version: 1,
          clients: [{ address: 'x', name: 'x', kind: 'm', addedAt: 0 }],
        }),
      ),
    ).toThrow(ValidationError); // missing identityPublicKey
    expect(() =>
      parseCanonicalProfile(
        JSON.stringify({
          version: 1,
          clients: [
            {
              address: 'x',
              name: 'x',
              kind: 'm',
              addedAt: 0,
              identityPublicKey: 'NOTHEX',
              identityFingerprint: 'x',
            },
          ],
        }),
      ),
    ).toThrow(ValidationError); // identityPublicKey not 64 hex
  });

  test('parsed blob is fully equal to the input via JSON round-trip', () => {
    let blob = emptyCanonicalProfile(1);
    const host = makeHost();
    const c = makeClient({ name: 'phone', trusted: true });
    blob = upsertHost(blob, host, 2);
    blob = upsertClient(blob, c.entry, 3);
    blob.signedBy = 'aabbccdd';

    const bytes = serializeCanonicalProfile(blob);
    const parsed = parseCanonicalProfile(bytes);
    expect(parsed).toEqual(blob);
  });
});

describe('Approval signing payload', () => {
  test('canonicalApprovalSigningBytes is deterministic', () => {
    const a = canonicalApprovalSigningBytes({
      domain: 'shade-link-approve-v1',
      requestId: 'aabbccddeeff00112233445566778899',
      hostFingerprint: '11111 22222 33333 44444',
      requestingDeviceFingerprint: '55555 66666 77777 88888',
      decision: 'approve',
    });
    const b = canonicalApprovalSigningBytes({
      domain: 'shade-link-approve-v1',
      requestId: 'aabbccddeeff00112233445566778899',
      hostFingerprint: '11111 22222 33333 44444',
      requestingDeviceFingerprint: '55555 66666 77777 88888',
      decision: 'approve',
    });
    expect(Buffer.from(a).toString('hex')).toBe(Buffer.from(b).toString('hex'));
  });

  test('different decision produces different signing bytes', () => {
    const base = {
      domain: 'shade-link-approve-v1',
      requestId: 'aabbccddeeff00112233445566778899',
      hostFingerprint: 'h',
      requestingDeviceFingerprint: 'r',
    };
    const approveBytes = canonicalApprovalSigningBytes({ ...base, decision: 'approve' });
    const rejectBytes = canonicalApprovalSigningBytes({ ...base, decision: 'reject' });
    expect(Buffer.from(approveBytes).toString('hex')).not.toBe(
      Buffer.from(rejectBytes).toString('hex'),
    );
  });

  test('different domain produces different signing bytes', () => {
    const a = canonicalApprovalSigningBytes({
      domain: 'shade-link-approve-v1',
      requestId: 'r',
      hostFingerprint: 'h',
      requestingDeviceFingerprint: 'd',
      decision: 'approve',
    });
    const b = canonicalApprovalSigningBytes({
      domain: 'prism-link-approve-v1',
      requestId: 'r',
      hostFingerprint: 'h',
      requestingDeviceFingerprint: 'd',
      decision: 'approve',
    });
    expect(Buffer.from(a).toString('hex')).not.toBe(Buffer.from(b).toString('hex'));
  });
});

describe('Build / sign / verify proxy approval', () => {
  function buildScenario() {
    const phone = makeClient({ name: 'phone', trusted: true });
    let profile = emptyCanonicalProfile(0);
    profile = upsertHost(profile, makeHost());
    profile = upsertClient(profile, phone.entry);

    const request = buildApprovalRequest({
      hostAddress: 'device:host-server',
      hostFingerprint: 'host-fp-12345',
      requestingDevice: {
        fingerprint: 'cafe-laptop-fp-67890',
        deviceName: 'cafe-laptop',
        userAgent: 'Mozilla/5.0',
        ipHint: '203.0.113.7',
      },
      crypto,
    });

    return { phone, profile, request };
  }

  test('happy path: signed approve verifies', async () => {
    const { phone, profile, request } = buildScenario();

    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });

    expect(approval.kind).toBe('linkApproveByProxy');
    expect(approval.requestId).toBe(request.requestId);
    expect(approval.signature.length).toBe(128); // 64-byte sig as hex

    const verdict = await verifyProxyApproval({
      request,
      approval,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(true);
    if (verdict.ok) {
      expect(verdict.approver.address).toBe(phone.entry.address);
    }
  });

  test('happy path: signed reject verifies', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'reject',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });
    const verdict = await verifyProxyApproval({ request, approval, profile, crypto });
    expect(verdict.ok).toBe(true);
  });

  test('replay against a different request fails (request-id-mismatch)', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });

    const otherRequest: ApprovalRequestFrame = {
      ...request,
      requestId: 'ffffffffffffffffffffffffffffffff',
    };
    const verdict = await verifyProxyApproval({
      request: otherRequest,
      approval,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('request-id-mismatch');
  });

  test('decision tampered after signing fails (bad-signature)', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });

    const tampered = { ...approval, decision: 'reject' as const };
    const verdict = await verifyProxyApproval({
      request,
      approval: tampered,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
  });

  test('host fingerprint substitution fails (bad-signature)', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });

    // Verifier sees the same approval but a different host fingerprint
    // (simulates an attacker forwarding an approval to a different host).
    const swappedRequest: ApprovalRequestFrame = {
      ...request,
      hostFingerprint: 'evil-host-fp',
    };
    const verdict = await verifyProxyApproval({
      request: swappedRequest,
      approval,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
  });

  test('domain mismatch is rejected before signature check', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });
    const tampered = { ...approval, domain: 'prism-link-approve-v1' };
    const verdict = await verifyProxyApproval({
      request,
      approval: tampered,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('domain-mismatch');
  });

  test('unknown approver fingerprint fails', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });
    const lying = { ...approval, approverFingerprint: 'no-such-fingerprint' };
    const verdict = await verifyProxyApproval({
      request,
      approval: lying,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('unknown-approver');
  });

  test('revoked approver (trustedApprover off) fails with not-trusted', async () => {
    const { phone, profile: original, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });
    // Simulate a revoke: workstation toggles the trustedApprover flag off
    // and PUTs the new blob; host re-fetches before verifying.
    const revoked = setTrustedApprover(
      original,
      phone.entry.identityFingerprint,
      false,
    );
    const verdict = await verifyProxyApproval({
      request,
      approval,
      profile: revoked,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('not-trusted');
  });

  test('expired request is rejected', async () => {
    const { phone, profile, request } = buildScenario();
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });
    const verdict = await verifyProxyApproval({
      request,
      approval,
      profile,
      crypto,
      now: () => request.expiresAt + 1,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('expired');
  });

  test('signature with the wrong key fails', async () => {
    const { phone, profile, request } = buildScenario();
    const wrongSeed = randBytes(32);
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint, // claim phone
      approverSigningKey: wrongSeed, // but sign with someone else's key
      crypto,
    });
    const verdict = await verifyProxyApproval({
      request,
      approval,
      profile,
      crypto,
    });
    expect(verdict.ok).toBe(false);
    if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
  });

  test('default domain is `shade-link-approve-v1`', () => {
    const r = buildApprovalRequest({
      hostAddress: 'h',
      hostFingerprint: 'h',
      requestingDevice: { fingerprint: 'r' },
      crypto,
    });
    expect(r.domain).toBe(DEFAULT_APPROVAL_DOMAIN);
    expect(DEFAULT_APPROVAL_DOMAIN).toBe('shade-link-approve-v1');
  });

  test('custom domain (e.g. `prism-link-approve-v1`) survives round-trip', async () => {
    const { phone, profile } = buildScenario();
    const request = buildApprovalRequest({
      hostAddress: 'device:host-server',
      hostFingerprint: 'h',
      requestingDevice: { fingerprint: 'r' },
      crypto,
      domain: 'prism-link-approve-v1',
    });
    const approval = await signProxyApproval({
      request,
      decision: 'approve',
      approverFingerprint: phone.entry.identityFingerprint,
      approverSigningKey: phone.signingSeed,
      crypto,
    });
    expect(approval.domain).toBe('prism-link-approve-v1');
    const verdict = await verifyProxyApproval({ request, approval, profile, crypto });
    expect(verdict.ok).toBe(true);
  });

  test('requestId is 32 lowercase hex chars (128 bits)', () => {
    const r = buildApprovalRequest({
      hostAddress: 'h',
      hostFingerprint: 'h',
      requestingDevice: { fingerprint: 'r' },
      crypto,
    });
    expect(/^[0-9a-f]{32}$/.test(r.requestId)).toBe(true);
  });

  test('two consecutive builds produce distinct requestIds', () => {
    const a = buildApprovalRequest({
      hostAddress: 'h',
      hostFingerprint: 'h',
      requestingDevice: { fingerprint: 'r' },
      crypto,
    });
    const b = buildApprovalRequest({
      hostAddress: 'h',
      hostFingerprint: 'h',
      requestingDevice: { fingerprint: 'r' },
      crypto,
    });
    expect(a.requestId).not.toBe(b.requestId);
  });
});
