import type { Repository, EntityManager, FindManyOptions, FindOneOptions } from 'typeorm';
import type { ClsService } from 'nestjs-cls';
import { TenantAwareRepository } from './tenant-aware.repository';
import { BaseEntity } from '../../database/base.entity';
import { CLS_TENANT_ID, CLS_USER_ID } from '../cls/cls.constants';

/**
 * Concrete subclass to drive the abstract TenantAwareRepository in tests.
 * Only used here; production code uses real per-entity repository classes.
 */
class TestEntity extends BaseEntity {
  name!: string;
}
class TestRepository extends TenantAwareRepository<TestEntity> {}

describe('TenantAwareRepository', () => {
  // ── Mocks ──────────────────────────────────────────────────────────────────
  let mockManager: EntityManager;
  let mockRepo: jest.Mocked<Repository<TestEntity>>;
  let cls: { get: jest.Mock };
  let repo: TestRepository;

  const TENANT_ID = 'tenant-1';
  const USER_ID = 'user-1';

  beforeEach(() => {
    mockManager = { query: jest.fn() } as unknown as EntityManager;
    mockRepo = {
      manager: mockManager,
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockResolvedValue(null),
      findAndCount: jest.fn().mockResolvedValue([[], 0]),
      save: jest.fn(),
      update: jest.fn().mockResolvedValue({ affected: 1 }),
      count: jest.fn().mockResolvedValue(0),
    } as unknown as jest.Mocked<Repository<TestEntity>>;
    cls = {
      get: jest.fn((key: string) => {
        if (key === CLS_TENANT_ID) return TENANT_ID;
        if (key === CLS_USER_ID) return USER_ID;
        return undefined;
      }),
    };
    repo = new TestRepository(mockRepo, cls as unknown as ClsService);
  });

  afterEach(() => jest.clearAllMocks());

  // ── Public accessors ───────────────────────────────────────────────────────
  describe('public accessors', () => {
    it('manager getter exposes the underlying EntityManager', () => {
      expect(repo.manager).toBe(mockManager);
    });

    it('currentTenantId() returns the tenant_id stored in CLS', () => {
      expect(repo.currentTenantId()).toBe(TENANT_ID);
    });

    it('currentTenantId() throws when CLS has no tenant_id (middleware never ran)', () => {
      cls.get.mockReturnValue(undefined);
      expect(() => repo.currentTenantId()).toThrow(/tenant_id not found in CLS/);
    });

    it('currentUserId() returns the user_id stored in CLS', () => {
      expect(repo.currentUserId()).toBe(USER_ID);
    });

    it('currentUserId() returns null on public endpoints (no user in CLS)', () => {
      cls.get.mockImplementation((key: string) => (key === CLS_TENANT_ID ? TENANT_ID : undefined));
      expect(repo.currentUserId()).toBeNull();
    });
  });

  // ── find ───────────────────────────────────────────────────────────────────
  describe('find', () => {
    it('auto-scopes tenant_id and is_deleted=false', async () => {
      await repo.find();
      expect(mockRepo.find).toHaveBeenCalledWith({
        where: { tenant_id: TENANT_ID, is_deleted: false },
      });
    });

    it('merges caller-supplied where with the scoping filters', async () => {
      await repo.find({ where: { name: 'foo' } } as FindManyOptions<TestEntity>);
      expect(mockRepo.find).toHaveBeenCalledWith({
        where: { name: 'foo', tenant_id: TENANT_ID, is_deleted: false },
      });
    });

    it('forwards relations / order / take options unchanged', async () => {
      await repo.find({
        relations: { tenant: true },
        order: { name: 'ASC' },
        take: 5,
      } as FindManyOptions<TestEntity>);
      expect(mockRepo.find).toHaveBeenCalledWith({
        where: { tenant_id: TENANT_ID, is_deleted: false },
        relations: { tenant: true },
        order: { name: 'ASC' },
        take: 5,
      });
    });
  });

  // ── findOne ────────────────────────────────────────────────────────────────
  describe('findOne', () => {
    it('auto-scopes tenant_id and is_deleted=false', async () => {
      await repo.findOne({ where: { id: 'x' } } as FindOneOptions<TestEntity>);
      expect(mockRepo.findOne).toHaveBeenCalledWith({
        where: { id: 'x', tenant_id: TENANT_ID, is_deleted: false },
      });
    });

    it('returns null when no row matches', async () => {
      mockRepo.findOne.mockResolvedValueOnce(null);
      const result = await repo.findOne({ where: { id: 'x' } } as FindOneOptions<TestEntity>);
      expect(result).toBeNull();
    });
  });

  // ── findById ───────────────────────────────────────────────────────────────
  describe('findById', () => {
    it('delegates to findOne with id-only where', async () => {
      await repo.findById('abc');
      expect(mockRepo.findOne).toHaveBeenCalledWith({
        where: { id: 'abc', tenant_id: TENANT_ID, is_deleted: false },
      });
    });
  });

  // ── save ───────────────────────────────────────────────────────────────────
  describe('save', () => {
    it('injects tenant_id, created_by, and updated_by from CLS', async () => {
      mockRepo.save.mockImplementationOnce((async (entity: TestEntity) => entity) as never);
      await repo.save({ name: 'foo' });
      expect(mockRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({
          name: 'foo',
          tenant_id: TENANT_ID,
          created_by: USER_ID,
          updated_by: USER_ID,
        }),
        undefined,
      );
    });

    it('preserves an explicit created_by (e.g. system-seeded rows)', async () => {
      mockRepo.save.mockImplementationOnce((async (entity: TestEntity) => entity) as never);
      await repo.save({ name: 'foo', created_by: 'seeder' });
      expect(mockRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({ created_by: 'seeder', updated_by: USER_ID }),
        undefined,
      );
    });

    it('forwards save options', async () => {
      mockRepo.save.mockImplementationOnce((async (entity: TestEntity) => entity) as never);
      await repo.save({ name: 'foo' }, { reload: false });
      expect(mockRepo.save).toHaveBeenCalledWith(expect.anything(), { reload: false });
    });
  });

  // ── saveMany ───────────────────────────────────────────────────────────────
  describe('saveMany', () => {
    it('injects tenant + audit fields on every entity', async () => {
      mockRepo.save.mockImplementationOnce((async (entities: TestEntity[]) => entities) as never);
      await repo.saveMany([{ name: 'a' }, { name: 'b' }]);
      expect(mockRepo.save).toHaveBeenCalledWith(
        expect.arrayContaining([
          expect.objectContaining({ name: 'a', tenant_id: TENANT_ID, updated_by: USER_ID }),
          expect.objectContaining({ name: 'b', tenant_id: TENANT_ID, updated_by: USER_ID }),
        ]),
        undefined,
      );
    });
  });

  // ── update ─────────────────────────────────────────────────────────────────
  describe('update', () => {
    it('scopes the WHERE clause to the current tenant', async () => {
      await repo.update('row-id', { name: 'new' } as Partial<TestEntity>);
      expect(mockRepo.update).toHaveBeenCalledWith(
        { id: 'row-id', tenant_id: TENANT_ID },
        expect.objectContaining({ name: 'new', updated_by: USER_ID }),
      );
    });

    it('auto-sets updated_by from CLS', async () => {
      await repo.update('row-id', { name: 'new' } as Partial<TestEntity>);
      expect(mockRepo.update).toHaveBeenCalledWith(
        expect.anything(),
        expect.objectContaining({ updated_by: USER_ID }),
      );
    });
  });

  // ── softDelete ─────────────────────────────────────────────────────────────
  describe('softDelete', () => {
    it('sets is_deleted=true within the tenant scope', async () => {
      await repo.softDelete('row-id');
      expect(mockRepo.update).toHaveBeenCalledWith(
        { id: 'row-id', tenant_id: TENANT_ID },
        expect.objectContaining({ is_deleted: true, updated_by: USER_ID }),
      );
    });

    it('NEVER hard-deletes (no .delete or .remove call)', async () => {
      const hardDelete = jest.fn();
      const hardRemove = jest.fn();
      (mockRepo as unknown as { delete: jest.Mock }).delete = hardDelete;
      (mockRepo as unknown as { remove: jest.Mock }).remove = hardRemove;
      await repo.softDelete('row-id');
      expect(hardDelete).not.toHaveBeenCalled();
      expect(hardRemove).not.toHaveBeenCalled();
    });
  });

  // ── count ──────────────────────────────────────────────────────────────────
  describe('count', () => {
    it('auto-scopes tenant_id and is_deleted=false', async () => {
      await repo.count();
      expect(mockRepo.count).toHaveBeenCalledWith({
        where: { tenant_id: TENANT_ID, is_deleted: false },
      });
    });

    it('merges caller-supplied where with the scoping filters', async () => {
      await repo.count({ where: { name: 'foo' } } as FindManyOptions<TestEntity>);
      expect(mockRepo.count).toHaveBeenCalledWith({
        where: { name: 'foo', tenant_id: TENANT_ID, is_deleted: false },
      });
    });
  });

  // ── findPaginated ──────────────────────────────────────────────────────────
  describe('findPaginated', () => {
    it('applies sensible defaults (page=1, limit=10)', async () => {
      await repo.findPaginated({});
      expect(mockRepo.findAndCount).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { tenant_id: TENANT_ID, is_deleted: false },
          skip: 0,
          take: 10,
        }),
      );
    });

    it('computes skip from page + limit', async () => {
      await repo.findPaginated({ page: 3, limit: 25 });
      expect(mockRepo.findAndCount).toHaveBeenCalledWith(
        expect.objectContaining({ skip: 50, take: 25 }),
      );
    });

    it('returns the paginated envelope { items, meta }', async () => {
      mockRepo.findAndCount.mockResolvedValueOnce([
        [{ id: '1', name: 'foo' } as unknown as TestEntity],
        12,
      ]);
      const result = await repo.findPaginated({ page: 2, limit: 5 });
      expect(result).toEqual({
        items: [{ id: '1', name: 'foo' }],
        meta: { total: 12, page: 2, limit: 5, totalPages: 3 },
      });
    });

    it('skips search when search string is empty', async () => {
      await repo.findPaginated({ search: '', searchColumns: ['name'] });
      const call = mockRepo.findAndCount.mock.calls[0][0]!;
      // No array of ORs; just the base scoping where.
      expect(Array.isArray(call.where)).toBe(false);
    });

    it('builds OR conditions across searchColumns for a single search word', async () => {
      await repo.findPaginated({ search: 'foo', searchColumns: ['name', 'description'] });
      const call = mockRepo.findAndCount.mock.calls[0][0]!;
      expect(Array.isArray(call.where)).toBe(true);
      expect((call.where as object[]).length).toBe(2);
    });

    it('builds multi-word search conditions (original + per-word matches)', async () => {
      await repo.findPaginated({
        search: 'toyota hiace',
        searchColumns: ['name', 'description'],
      });
      const call = mockRepo.findAndCount.mock.calls[0][0]!;
      expect(Array.isArray(call.where)).toBe(true);
      // 2 original-match + 4 per-word (2 words × 2 cols) = 6
      expect((call.where as object[]).length).toBeGreaterThanOrEqual(6);
    });

    it('falls back to base scoping when search is all special chars', async () => {
      await repo.findPaginated({ search: '→ → →', searchColumns: ['name'] });
      const call = mockRepo.findAndCount.mock.calls[0][0]!;
      expect(Array.isArray(call.where)).toBe(false);
    });

    it('converts string-array relations to TypeORM-1 nested-object form', async () => {
      await repo.findPaginated({ relations: ['tenant', 'tags.users'] });
      const call = mockRepo.findAndCount.mock.calls[0][0]!;
      expect(call.relations).toEqual({ tenant: true, tags: { users: true } });
    });

    it('forwards order through unchanged', async () => {
      await repo.findPaginated({ order: { name: 'ASC' } });
      expect(mockRepo.findAndCount).toHaveBeenCalledWith(
        expect.objectContaining({ order: { name: 'ASC' } }),
      );
    });
  });

  // ── Tenant isolation — REQUIRED per .claude/rules/backend.md §Testing ─────
  describe('tenant isolation (CRITICAL — multi-tenant SaaS guarantee)', () => {
    it('every scoped query injects the CURRENT tenant_id from CLS', async () => {
      cls.get.mockImplementation((key: string) => {
        if (key === CLS_TENANT_ID) return 'tenant-A';
        if (key === CLS_USER_ID) return USER_ID;
        return undefined;
      });
      const repoA = new TestRepository(mockRepo, cls as unknown as ClsService);
      await repoA.find();
      await repoA.findOne({ where: { id: 'x' } } as FindOneOptions<TestEntity>);
      await repoA.count();
      await repoA.findPaginated({});
      await repoA.update('id', {} as Partial<TestEntity>);
      await repoA.softDelete('id');

      const calls = [
        ...mockRepo.find.mock.calls,
        ...mockRepo.findOne.mock.calls,
        ...mockRepo.count.mock.calls,
        ...mockRepo.findAndCount.mock.calls,
        ...mockRepo.update.mock.calls,
      ];
      for (const call of calls) {
        const [first, second] = call;
        // first arg is either the where filter (update) or an options
        // object with .where. Check both shapes.
        const target =
          (first as { where?: object; tenant_id?: string }).where ??
          (first as { tenant_id?: string });
        const tenant = (target as { tenant_id?: string } | undefined)?.tenant_id;
        // If the where is an array (search OR conditions), spot-check
        // the first member.
        const resolved = Array.isArray(target)
          ? (target as Array<{ tenant_id?: string }>)[0]?.tenant_id
          : tenant;
        // update() passes a flat where as the first arg, then changes as second;
        // for those we already captured tenant via `first`. Anything else came
        // through the options object.
        const actual = resolved ?? (second as { tenant_id?: string } | undefined)?.tenant_id;
        expect(actual).toBe('tenant-A');
        expect(actual).not.toBe('tenant-B');
      }
    });

    it('a switch of CLS tenant scopes all subsequent calls to the new tenant', async () => {
      cls.get.mockImplementation((key: string) => {
        if (key === CLS_TENANT_ID) return 'tenant-B';
        if (key === CLS_USER_ID) return USER_ID;
        return undefined;
      });
      const repoB = new TestRepository(mockRepo, cls as unknown as ClsService);
      await repoB.find();
      const call = mockRepo.find.mock.calls[0][0]!;
      expect((call.where as { tenant_id: string }).tenant_id).toBe('tenant-B');
    });

    it('fails fast with a clear error when CLS has no tenant_id', async () => {
      cls.get.mockReturnValue(undefined);
      const naked = new TestRepository(mockRepo, cls as unknown as ClsService);
      await expect(naked.find()).rejects.toThrow(/tenant_id not found in CLS/);
      await expect(naked.save({ name: 'x' })).rejects.toThrow(/tenant_id not found in CLS/);
      await expect(naked.update('id', {} as Partial<TestEntity>)).rejects.toThrow(
        /tenant_id not found in CLS/,
      );
      await expect(naked.softDelete('id')).rejects.toThrow(/tenant_id not found in CLS/);
    });
  });
});
