import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { eq, and } from 'drizzle-orm';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import type { FederationRelayEvent, FederationRelayParticipant } from '@backspace/shared';

setWorkerId(4);

const __dirname = path.dirname(fileURLToPath(import.meta.url));

type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;

vi.mock('../db/index.js', () => ({
  getDb: () => testDb,
  getRawDb: () => sqlite,
  schema,
}));

vi.mock('../config.js', () => ({
  config: {
    domain: 'local.test',
    port: 3000,
    host: '0.0.0.0',
    jwtSecret: 'test-secret-12345678901234567890123456789012',
    maxUploadSize: 100 * 1024 * 1024,
    registrationOpen: true,
    uploadDir: '/tmp/bs-fed-kick-test',
  },
}));

vi.mock('../ws/handler.js', () => ({
  connectionManager: {
    sendToUser: vi.fn(),
    sendToSpace: vi.fn(),
    sendToDmMembers: vi.fn(),
    sendToAdmins: vi.fn(),
    getAllOnlineUserIds: () => [],
    evictFederatedCallsForHost: vi.fn(),
    federatedCalls: new Map(),
    isUserOnline: vi.fn(),
    lateBindFederatedCall: vi.fn(),
  },
}));

import { connectionManager } from '../ws/handler.js';

function applyMigrations(db: Database.Database): void {
  const migrationsDir = path.resolve(__dirname, '../../drizzle');
  const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
  for (const f of files) {
    const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
    for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
      const clean = stmt.trim();
      if (clean) db.exec(clean);
    }
  }
}

const OWNER_ORIGIN = 'https://owner.test';
const ATTACKER_ORIGIN = 'https://attacker.test';
const RANDOM_ORIGIN = 'https://random.test';

const FEDERATED_ID = 'fed-channel-kick-1';
const CHANNEL_ID = 'ch-kick-local-1';

const OWNER_USER_ID = 'owner-user-stub';
const OWNER_HOME_USER_ID = 'home-owner-1';

const VICTIM_USER_ID = 'victim-user-stub';
const VICTIM_HOME_USER_ID = 'home-victim-1';
const VICTIM_HOME_INSTANCE = 'https://victim.test';

const LEAVER_USER_ID = 'leaver-user-stub';
const LEAVER_HOME_USER_ID = 'home-leaver-1';
const LEAVER_HOME_INSTANCE = RANDOM_ORIGIN;

/**
 * Seed a federated group DM channel where:
 *   - Owner is a replicated user from `OWNER_ORIGIN`
 *   - A victim member is a replicated user from `VICTIM_HOME_INSTANCE` (used for kick tests)
 *   - A leaver member is a replicated user from `LEAVER_HOME_INSTANCE` (used for the leave test)
 *
 * `ownerHomeInstance` is stored as the full origin string (`https://owner.test`)
 * to match the kick authority check, which compares `sourceInstance` to
 * `ownerHomeInstance` via strict string equality (federation.ts:4350).
 */
function seedChannelAndMembers(): void {
  const now = Date.now();
  testDb.insert(schema.users).values({
    id: OWNER_USER_ID,
    username: `owner@owner.test`,
    displayName: 'owner',
    passwordHash: '!federation-replicated',
    status: 'offline',
    isAdmin: 0,
    homeInstance: OWNER_ORIGIN,
    homeUserId: OWNER_HOME_USER_ID,
    createdAt: now,
  }).run();

  testDb.insert(schema.users).values({
    id: VICTIM_USER_ID,
    username: `victim@victim.test`,
    displayName: 'victim',
    passwordHash: '!federation-replicated',
    status: 'offline',
    isAdmin: 0,
    homeInstance: VICTIM_HOME_INSTANCE,
    homeUserId: VICTIM_HOME_USER_ID,
    createdAt: now,
  }).run();

  testDb.insert(schema.users).values({
    id: LEAVER_USER_ID,
    username: `leaver@random.test`,
    displayName: 'leaver',
    passwordHash: '!federation-replicated',
    status: 'offline',
    isAdmin: 0,
    homeInstance: LEAVER_HOME_INSTANCE,
    homeUserId: LEAVER_HOME_USER_ID,
    createdAt: now,
  }).run();

  testDb.insert(schema.dmChannels).values({
    id: CHANNEL_ID,
    federatedId: FEDERATED_ID,
    ownerId: OWNER_USER_ID,
    ownerHomeUserId: OWNER_HOME_USER_ID,
    ownerHomeInstance: OWNER_ORIGIN,
    name: 'group',
    icon: null,
    metadataUpdatedAt: 1000,
    createdAt: now,
  }).run();

  testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: OWNER_USER_ID, closed: 0 }).run();
  testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: VICTIM_USER_ID, closed: 0 }).run();
  testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: LEAVER_USER_ID, closed: 0 }).run();
}

function ownerActor(): FederationRelayParticipant {
  return {
    homeUserId: OWNER_HOME_USER_ID,
    homeInstance: OWNER_ORIGIN,
    profile: { username: 'owner', displayName: 'owner' },
  };
}

function victimActor(): FederationRelayParticipant {
  return {
    homeUserId: VICTIM_HOME_USER_ID,
    homeInstance: VICTIM_HOME_INSTANCE,
    profile: { username: 'victim', displayName: 'victim' },
  };
}

function leaverActor(): FederationRelayParticipant {
  return {
    homeUserId: LEAVER_HOME_USER_ID,
    homeInstance: LEAVER_HOME_INSTANCE,
    profile: { username: 'leaver', displayName: 'leaver' },
  };
}

function buildKickEvent(messageId = 'evt-kick-1'): FederationRelayEvent {
  return {
    eventType: 'member_remove',
    contextType: 'dm',
    messageId,
    federatedId: FEDERATED_ID,
    encryptionVersion: 0,
    timestamp: Date.now(),
    membership: {
      user: victimActor(),
      removedBy: ownerActor(),
      reason: 'kick',
    },
  };
}

function buildLeaveEvent(messageId = 'evt-leave-1'): FederationRelayEvent {
  return {
    eventType: 'member_remove',
    contextType: 'dm',
    messageId,
    federatedId: FEDERATED_ID,
    encryptionVersion: 0,
    timestamp: Date.now(),
    membership: {
      user: leaverActor(),
      removedBy: leaverActor(),
      reason: 'leave',
    },
  };
}

beforeEach(() => {
  sqlite = new Database(':memory:');
  testDb = drizzle(sqlite, { schema });
  applyMigrations(sqlite);
  vi.mocked(connectionManager.sendToDmMembers).mockReset();
});

describe('processMemberRemoveEvent — kick authority', () => {
  it('accepts a kick from the owner instance, deletes the member, inserts a system message, and broadcasts dm_member_removed', async () => {
    seedChannelAndMembers();
    const fed = await import('./federation.js');
    const event = buildKickEvent();

    const accepted: string[] = [];
    const rejected: Array<{ messageId: string; reason: string }> = [];
    fed.processMemberRemoveEvent(event, OWNER_ORIGIN, testDb, accepted, rejected);

    expect(rejected).toEqual([]);
    expect(accepted).toEqual([event.messageId]);

    // Victim's dm_members row was deleted
    const victimRow = testDb.select().from(schema.dmMembers)
      .where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, VICTIM_USER_ID)))
      .get();
    expect(victimRow).toBeUndefined();

    // System message inserted, tagged with source for dedup, with reason=kick
    const sysRows = testDb.select().from(schema.dmMessages)
      .where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID))
      .all();
    expect(sysRows).toHaveLength(1);
    const sys = sysRows[0]!;
    expect(sys.type).toBe('system');
    expect(sys.sourceInstance).toBe(OWNER_ORIGIN);
    expect(sys.sourceMessageId).toBe(event.messageId);
    const parsed = JSON.parse(sys.content!);
    expect(parsed.event).toBe('member_removed');
    expect(parsed.reason).toBe('kick');
    expect(parsed.targetUserId).toBe(VICTIM_USER_ID);

    // Two broadcasts fire: one for the system message, one for dm_member_removed
    const sendSpy = vi.mocked(connectionManager.sendToDmMembers);
    const removalCalls = sendSpy.mock.calls.filter(c => (c[1] as { type?: string }).type === 'dm_member_removed');
    expect(removalCalls).toHaveLength(1);
    const removalPayload = removalCalls[0]![1] as { type: string; dmChannelId: string; userId: string };
    expect(removalPayload).toEqual({
      type: 'dm_member_removed',
      dmChannelId: CHANNEL_ID,
      userId: VICTIM_USER_ID,
    });
  });

  it('rejects a kick from a non-owner instance and performs no mutation (current behavior: reason=unauthorized_source)', async () => {
    seedChannelAndMembers();
    const fed = await import('./federation.js');
    const event = buildKickEvent('evt-kick-attacker');

    const accepted: string[] = [];
    const rejected: Array<{ messageId: string; reason: string }> = [];
    fed.processMemberRemoveEvent(event, ATTACKER_ORIGIN, testDb, accepted, rejected);

    expect(accepted).toEqual([]);
    // Receiver currently emits `unauthorized_source` for non-owner kicks; the
    // group-DM-polish plan describes this rejection as `attribution_mismatch`.
    // The lock-in goal here is current behavior, so we assert the actual
    // string. Tighten this if the receiver is changed to align with the plan
    // wording — see federation.ts:processMemberRemoveEvent line ~4350.
    expect(rejected).toEqual([{ messageId: event.messageId, reason: 'unauthorized_source' }]);

    // Victim's dm_members row is STILL present (no mutation)
    const victimRow = testDb.select().from(schema.dmMembers)
      .where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, VICTIM_USER_ID)))
      .get();
    expect(victimRow).toBeDefined();

    // No system message inserted
    const sysRows = testDb.select().from(schema.dmMessages)
      .where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID))
      .all();
    expect(sysRows).toHaveLength(0);

    // No broadcast emitted at all
    expect(vi.mocked(connectionManager.sendToDmMembers)).not.toHaveBeenCalled();
  });

  it('accepts a self-leave from any source instance (not the owner instance) and removes the leaver', async () => {
    seedChannelAndMembers();
    const fed = await import('./federation.js');
    const event = buildLeaveEvent();

    const accepted: string[] = [];
    const rejected: Array<{ messageId: string; reason: string }> = [];
    // sourceInstance == leaver's home instance (RANDOM_ORIGIN), which is NOT
    // the owner's home instance — verifyAttribution still passes because the
    // leaving user belongs to the source.
    fed.processMemberRemoveEvent(event, RANDOM_ORIGIN, testDb, accepted, rejected);

    expect(rejected).toEqual([]);
    expect(accepted).toEqual([event.messageId]);

    // Leaver's dm_members row was deleted
    const leaverRow = testDb.select().from(schema.dmMembers)
      .where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, LEAVER_USER_ID)))
      .get();
    expect(leaverRow).toBeUndefined();

    // System message inserted with reason=leave
    const sysRows = testDb.select().from(schema.dmMessages)
      .where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID))
      .all();
    expect(sysRows).toHaveLength(1);
    const parsed = JSON.parse(sysRows[0]!.content!);
    expect(parsed.event).toBe('member_removed');
    expect(parsed.reason).toBe('leave');
    expect(parsed.targetUserId).toBe(LEAVER_USER_ID);

    // dm_member_removed broadcast fires
    const sendSpy = vi.mocked(connectionManager.sendToDmMembers);
    const removalCalls = sendSpy.mock.calls.filter(c => (c[1] as { type?: string }).type === 'dm_member_removed');
    expect(removalCalls).toHaveLength(1);
    expect(removalCalls[0]![1]).toEqual({
      type: 'dm_member_removed',
      dmChannelId: CHANNEL_ID,
      userId: LEAVER_USER_ID,
    });
  });
});
