import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { connectTestDb } from '../../../../__tests__/helpers/db-connection.js';
import { runMigrationsIfNeeded } from '../../../../__tests__/helpers/db-migrations.js';
import { dropRlsRole, ensureRlsRole, runAsRlsRole } from '../../../../__tests__/helpers/rls-role.js';
import type { AdminContextProvider } from '../../../admin-context/providers/admin-context.provider.js';
import { DBProvider } from '../../../app-providers/db.provider.js';
import { TenantAwareDBClient } from '../../../app-providers/tenant-db-client.js';
import type { AuthContextProvider } from '../../../auth/providers/auth-context.provider.js';
import { BusinessesProvider } from '../../../financial-entities/providers/businesses.provider.js';
import { FinancialEntitiesProvider } from '../../../financial-entities/providers/financial-entities.provider.js';
import { TaxCategoriesProvider } from '../../../financial-entities/providers/tax-categories.provider.js';
import { SecurityBusinessesProvider } from '../security-businesses.provider.js';

let pool: Pool;

// Both new tables carry a NOT NULL owner_id FK, so the tenants this suite acts as must exist
// in accounter_schema.businesses. Seeded in beforeAll, removed in afterAll.
const TEST_OWNER_ID = '00000000-0000-0000-0000-0000000006f0';
const OTHER_OWNER_ID = '00000000-0000-0000-0000-0000000006f1';
const GENERAL_SECURITIES_BUSINESS_ID = '00000000-0000-0000-0000-0000000006f2';

function createMockAuthContextProvider(businessId: string): AuthContextProvider {
  return {
    getAuthContext: () =>
      Promise.resolve({
        authType: 'apiKey' as const,
        token: 'test-token',
        tenant: { businessId },
        user: {
          userId: 'api-key:test',
          auth0UserId: null,
          email: '',
          roleId: 'admin',
          permissions: [],
          emailVerified: true,
          permissionsVersion: 0,
        },
      }),
  } as unknown as AuthContextProvider;
}

/**
 * Only the fields ensureSecurityBusiness reads. The general foreign-securities business is a
 * real row (seeded below) so the inheritance of sort code / IRS code / country is exercised
 * rather than stubbed.
 */
function createMockAdminContextProvider(ownerId: string): AdminContextProvider {
  return {
    getVerifiedAdminContext: () =>
      Promise.resolve({
        ownerId,
        foreignSecurities: {
          foreignSecuritiesBusinessId: GENERAL_SECURITIES_BUSINESS_ID,
          foreignSecuritiesFeesCategoryId: null,
        },
      }),
  } as unknown as AdminContextProvider;
}

function createProvider(ownerId = TEST_OWNER_ID) {
  const dbClient = new TenantAwareDBClient(
    new DBProvider(pool),
    createMockAuthContextProvider(ownerId),
  );
  const adminContextProvider = createMockAdminContextProvider(ownerId);
  const businessesProvider = new BusinessesProvider(dbClient, adminContextProvider);
  const taxCategoriesProvider = new TaxCategoriesProvider(dbClient, adminContextProvider);
  const financialEntitiesProvider = new FinancialEntitiesProvider(
    dbClient,
    businessesProvider,
    // BusinessesOperationProvider is only used by delete/replace paths this suite never takes.
    {} as never,
    taxCategoriesProvider,
  );

  return new SecurityBusinessesProvider(
    dbClient,
    adminContextProvider,
    financialEntitiesProvider,
    businessesProvider,
    taxCategoriesProvider,
  );
}

const APPLE = {
  isin: 'US0378331005',
  symbol: 'AAPL',
  engName: 'APPLE INC',
  hebName: 'אפל',
  exchange: 'NYQ',
  currencyCode: 'USD',
  isForeign: true,
};

/**
 * `financial_entities.owner_id` is an FK to `businesses`, so a tenant's own row must be
 * self-owned (entity first with a null owner, then the business pointing at itself).
 */
async function seedBusiness(id: string, ownerId: string | null, name: string) {
  await pool.query(
    `INSERT INTO accounter_schema.financial_entities (id, name, type, owner_id)
     VALUES ($1, $2, 'business', $3)
     ON CONFLICT (id) DO NOTHING`,
    [id, name, ownerId],
  );
  await pool.query(
    `INSERT INTO accounter_schema.businesses (id, owner_id, country)
     VALUES ($1, $2, 'ISR')
     ON CONFLICT (id) DO NOTHING`,
    [id, ownerId ?? id],
  );
}

beforeAll(async () => {
  pool = await connectTestDb();
  await runMigrationsIfNeeded(pool);

  for (const [index, ownerId] of [TEST_OWNER_ID, OTHER_OWNER_ID].entries()) {
    await seedBusiness(ownerId, null, `security-businesses-test-owner-${index}`);
  }
  await seedBusiness(GENERAL_SECURITIES_BUSINESS_ID, TEST_OWNER_ID, 'Foreign Securities');

  await ensureRlsRole(pool, {
    grants: [
      { table: 'businesses_securities', privileges: 'SELECT' },
      { table: 'security_identifiers', privileges: 'SELECT' },
    ],
  });
});

afterAll(async () => {
  await dropRlsRole(pool);
  // businesses_securities and security_identifiers cascade with the owning business.
  for (const id of [GENERAL_SECURITIES_BUSINESS_ID, TEST_OWNER_ID, OTHER_OWNER_ID]) {
    await pool.query('DELETE FROM accounter_schema.businesses WHERE owner_id = $1', [id]);
    await pool.query('DELETE FROM accounter_schema.businesses WHERE id = $1', [id]);
    await pool.query('DELETE FROM accounter_schema.financial_entities WHERE owner_id = $1', [id]);
    await pool.query('DELETE FROM accounter_schema.financial_entities WHERE id = $1', [id]);
  }
  // Do NOT close the pool here — it is shared with other concurrently-running suites.
});

// DELETE rather than TRUNCATE CASCADE: TRUNCATE takes ACCESS EXCLUSIVE locks that deadlock
// against concurrently-running integration suites. Scoped to this suite's owners.
beforeEach(async () => {
  const owners = [TEST_OWNER_ID, OTHER_OWNER_ID];
  await pool.query(
    `DELETE FROM accounter_schema.businesses
     WHERE owner_id = ANY($1)
       AND id IN (SELECT id FROM accounter_schema.businesses_securities)`,
    [owners],
  );
  await pool.query(
    `DELETE FROM accounter_schema.financial_entities
     WHERE owner_id = ANY($1)
       AND id NOT IN ($2, $3, $4)`,
    [owners, TEST_OWNER_ID, OTHER_OWNER_ID, GENERAL_SECURITIES_BUSINESS_ID],
  );
});

describe('ensureSecurityBusiness', () => {
  it('creates a business named after the security and marks it as one', async () => {
    const provider = createProvider();

    const security = await provider.ensureSecurityBusiness(APPLE);

    expect(security.isin).toBe(APPLE.isin);
    expect(security.owner_id).toBe(TEST_OWNER_ID);
    expect(security.symbol).toBe('AAPL');

    const { rows } = await pool.query(
      `SELECT fe.name, fe.type, b.hebrew_name, b.country
       FROM accounter_schema.financial_entities fe
       INNER JOIN accounter_schema.businesses b USING (id)
       WHERE fe.id = $1`,
      [security.id],
    );
    expect(rows[0]).toMatchObject({
      name: 'APPLE INC (AAPL)',
      type: 'business',
      hebrew_name: 'אפל',
      country: 'ISR',
    });
  });

  it('normalizes the currency the feed reports into the closed type', async () => {
    const provider = createProvider();

    // The Poalim securities feed spells its currencies out in Hebrew.
    const security = await provider.ensureSecurityBusiness({
      ...APPLE,
      currencyCode: 'דולר ארה"ב',
    });

    expect(security.currency_code).toBe('USD');
  });

  it('leaves the currency empty rather than failing on a label it cannot resolve', async () => {
    const provider = createProvider();

    const security = await provider.ensureSecurityBusiness({
      ...APPLE,
      currencyCode: 'ZZZ',
    });

    expect(security.currency_code).toBeNull();
    expect(security.isin).toBe(APPLE.isin);
  });

  it('does not read a missing currency as the local one', async () => {
    const provider = createProvider();

    const security = await provider.ensureSecurityBusiness({ ...APPLE, currencyCode: '  ' });

    expect(security.currency_code).toBeNull();
  });

  it('settles a whole payload in one lookup, creating only what is missing', async () => {
    const provider = createProvider();
    const first = await provider.ensureSecurityBusiness(APPLE);

    const securities = await provider.ensureSecurityBusinesses([
      APPLE,
      { isin: 'US5949181045', symbol: 'MSFT', engName: 'MICROSOFT CORP', isForeign: true },
    ]);

    expect(securities.get(APPLE.isin)?.id).toBe(first.id);
    expect(securities.get('US5949181045')?.symbol).toBe('MSFT');
    const { rows } = await pool.query(
      'SELECT count(*)::int AS count FROM accounter_schema.businesses_securities WHERE owner_id = $1',
      [TEST_OWNER_ID],
    );
    expect(rows[0].count).toBe(2);
  });

  it('is idempotent per ISIN — a second call returns the same business', async () => {
    const provider = createProvider();

    const first = await provider.ensureSecurityBusiness(APPLE);
    const second = await provider.ensureSecurityBusiness({ ...APPLE, engName: 'APPLE INCORPORATED' });

    expect(second.id).toBe(first.id);
    const { rows } = await pool.query(
      'SELECT count(*)::int AS count FROM accounter_schema.businesses_securities WHERE owner_id = $1',
      [TEST_OWNER_ID],
    );
    expect(rows[0].count).toBe(1);
  });

  /**
   * The provider's queries carry no owner_id predicate — RLS is what scopes them. The test
   * pool connects as postgres (BYPASSRLS), so asserting this through the provider would
   * prove nothing; this runs the same query shape under a non-superuser role, which is the
   * boundary the server actually operates behind.
   */
  it("does not expose another tenant's securities under the tenant_isolation policy", async () => {
    const security = await createProvider(TEST_OWNER_ID).ensureSecurityBusiness(APPLE);
    await createProvider(TEST_OWNER_ID).linkIdentifier(
      security.id,
      'POALIM_SECURITY_KEY',
      '5129523',
    );

    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      // Session variables must be set as superuser, before privileges are dropped.
      await client.query(`SELECT set_config('app.current_business_id', $1, true)`, [
        OTHER_OWNER_ID,
      ]);

      const rows = await runAsRlsRole(client, async () => {
        const securities = await client.query(
          'SELECT isin FROM accounter_schema.businesses_securities',
        );
        const identifiers = await client.query(
          'SELECT identifier_value FROM accounter_schema.security_identifiers',
        );
        return [...securities.rows, ...identifiers.rows];
      });

      expect(rows).toEqual([]);
    } finally {
      await client.query('ROLLBACK');
      client.release();
    }
  });
});

describe('linkIdentifier', () => {
  it('resolves a Poalim security key to its security business', async () => {
    const provider = createProvider();
    const security = await provider.ensureSecurityBusiness(APPLE);

    await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '5129523');

    const found = await provider.getSecurityBusinessByIdentifierLoader.load({
      ownerId: TEST_OWNER_ID,
      type: 'POALIM_SECURITY_KEY',
      value: '5129523',
    });
    expect(found?.id).toBe(security.id);
  });

  it('collapses several Poalim keys onto one ISIN, and is a no-op when repeated', async () => {
    const provider = createProvider();
    const security = await provider.ensureSecurityBusiness(APPLE);

    await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '5129523');
    await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '5129523');
    await provider.linkIdentifier(security.id, 'POALIM_SECURITY_KEY', '7654321');

    const identifiers = await provider.getIdentifiersByBusinessIdLoader.load(security.id);
    expect(identifiers.map(identifier => identifier.identifier_value)).toEqual([
      '5129523',
      '7654321',
    ]);
  });

  it('returns null for an unknown identifier', async () => {
    const provider = createProvider();

    const found = await provider.getSecurityBusinessByIdentifierLoader.load({
      ownerId: TEST_OWNER_ID,
      type: 'POALIM_SECURITY_KEY',
      value: '0000001',
    });

    expect(found).toBeNull();
  });
});
