import { expectType } from '@learnitive/mini-js-utils';
import { describe, expect, test, vi } from 'vitest';
import {
  Container,
  defineServiceMap,
  type Service,
  type ServiceContext,
  ServiceStartupError,
  type ServiceToConstructor,
  slot,
} from '../index';
import { recursiveInstantiate } from '../recurse';

describe('Container', () => {
  type TestContext = {
    env: string;
  };

  class ServiceA implements Service<TestContext> {
    constructor(
      _: { ctx: TestContext; serviceContext: ServiceContext },
      _deps: Record<string, never>,
      public config: { name: string },
    ) {}
  }

  class ServiceB implements Service<TestContext> {
    static deps = ['serviceA'] as const;
    constructor(
      _: unknown,
      public dependencies: { serviceA: ServiceA },
    ) {}
    postInstantiate() {}
  }

  class ServiceC implements Service<TestContext> {
    static deps = ['serviceA', 'serviceB'] as const;
    constructor(
      _: { ctx: TestContext; serviceContext: ServiceContext },
      public dependencies: { serviceA: ServiceA; serviceB: ServiceB },
      public config: { value: number },
    ) {}
    postInstantiate() {}
  }

  test('basic instantiation and type inference', () => {
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: slot(ServiceA, () => ({ name: 'test' })),
        serviceB: ServiceB,
        serviceC: slot(ServiceC, () => ({ value: 42 })),
      },
    );

    const services = container.instantiateAll();

    expect(services.serviceA.config.name).toBe('test');
    expect(services.serviceC.config.value).toBe(42);
    expect(services.serviceB.dependencies.serviceA).toBe(services.serviceA);
    expect(services.serviceC.dependencies.serviceA).toBe(services.serviceA);
    expect(services.serviceC.dependencies.serviceB).toBe(services.serviceB);
  });

  test('defineServiceMap preserves checked service inference', () => {
    const serviceMap = defineServiceMap<TestContext>()({
      serviceA: slot(ServiceA, () => ({ name: 'test' })),
      serviceB: ServiceB,
      serviceC: slot(ServiceC, () => ({ value: 42 })),
    });
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      serviceMap,
    );

    const services = container.instantiateAll();

    expect(services.serviceB.dependencies.serviceA).toBe(services.serviceA);
    expect(services.serviceC.dependencies.serviceB).toBe(services.serviceB);
  });

  test('defineServiceMap type-checks declared service dependencies', () => {
    class MissingStaticDep implements Service<TestContext> {
      constructor(
        _: { ctx: TestContext; serviceContext: ServiceContext },
        public dependencies: { serviceA: ServiceA },
      ) {}
    }

    class UnknownStaticDep implements Service<TestContext> {
      static deps = ['missingService'] as const;
      constructor(
        _: { ctx: TestContext; serviceContext: ServiceContext },
        public dependencies: { missingService: ServiceA },
      ) {}
    }

    class WrongDependencyType implements Service<TestContext> {
      static deps = ['serviceA'] as const;
      constructor(
        _: { ctx: TestContext; serviceContext: ServiceContext },
        public dependencies: { serviceA: ServiceC },
      ) {}
    }

    // @ts-expect-error constructor dependencies must be listed in static deps.
    defineServiceMap<TestContext>()({
      serviceA: slot(ServiceA, () => ({ name: 'x' })),
      missingStaticDep: MissingStaticDep,
    });

    // @ts-expect-error static deps must name a registered service slot.
    defineServiceMap<TestContext>()({
      serviceA: slot(ServiceA, () => ({ name: 'x' })),
      unknownStaticDep: UnknownStaticDep,
    });

    // @ts-expect-error registered dependency instances must satisfy constructor deps.
    defineServiceMap<TestContext>()({
      serviceA: slot(ServiceA, () => ({ name: 'x' })),
      wrongDependencyType: WrongDependencyType,
    });
  });

  test('a class with a required config cannot be registered bare', () => {
    // @ts-expect-error ServiceA requires a config; it must be registered via slot().
    defineServiceMap<TestContext>()({
      serviceA: ServiceA,
    });

    // @ts-expect-error the slot config factory must return the constructor's config type.
    slot(ServiceA, () => ({ name: 42 }));
  });

  test('slot config factories run lazily at instantiation time', () => {
    let name = 'early';
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: slot(ServiceA, () => ({ name })),
      },
    );

    name = 'late';
    const services = container.instantiateAll();

    expect(services.serviceA.config.name).toBe('late');
  });

  test('a throwing config factory fails the owning slot', () => {
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: slot(ServiceA, (): { name: string } => {
          throw new Error('boom');
        }),
      },
    );

    expect(() => container.instantiateAll()).toThrow(
      'Service "serviceA" failed during instantiate: boom',
    );
    expect(container.describe().failedSlot).toMatchObject({
      phase: 'instantiate',
      slotId: 'serviceA',
    });
  });

  test('a slot accepts a subclass with its own config', () => {
    class MockServiceA extends ServiceA {
      mockMethod() {
        return 'mocked';
      }
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: slot(MockServiceA, () => ({ name: 'mock' })),
        serviceB: ServiceB,
      },
    );

    const services = container.instantiateAll();

    expect(services.serviceA instanceof MockServiceA).toBe(true);
    expect(services.serviceA.mockMethod()).toBe('mocked');
    expect(services.serviceB.dependencies.serviceA).toBe(services.serviceA);
  });

  test("registering without config doesn't throw error", () => {
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceB: class ServiceB {
          constructor(
            public contex: { ctx: TestContext; serviceContext: ServiceContext },
            public _deps: Record<string, never>,
          ) {}
          postInstantiate() {}
        },
      },
    );
    container.instantiateAll();
  });

  test('abort signal propagation', () => {
    const abortController = new AbortController();
    const abortSpy = vi.fn();

    class TestServiceA implements Service<TestContext> {
      constructor(
        {
          serviceContext,
        }: { ctx: TestContext; serviceContext: ServiceContext },
        _deps: Record<string, never>,
      ) {
        serviceContext.abortSignal?.addEventListener('abort', () => {
          abortSpy();
        });
      }
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: abortController.signal },
      {
        serviceA: TestServiceA,
      },
    );

    container.instantiateAll();
    abortController.abort();
    expect(abortSpy).toHaveBeenCalled();
  });

  test('mounting services', async () => {
    const mountSpy = vi.fn();

    class MountableService implements Service<TestContext> {
      mountPromise: Promise<void>;

      constructor(
        _context: { ctx: TestContext; serviceContext: ServiceContext },
        _deps: Record<string, never>,
      ) {
        this.mountPromise = Promise.resolve().then(() => {
          mountSpy();
        });
      }
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        mountable: MountableService,
      },
    );

    const services = container.instantiateAll();

    await services.mountable.mountPromise;
    expect(mountSpy).toHaveBeenCalled();
  });

  test('prevents multiple instantiation', () => {
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: slot(ServiceA, () => ({ name: 'test' })),
      },
    );

    container.instantiateAll();

    expect(() => {
      container.instantiateAll();
    }).toThrowError('instantiateAll() can only be called once.');
  });

  test('interface placeholder behavior', () => {
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceInterface: {} as any,
      },
    );

    expect(() => {
      container.instantiateAll();
    }).toThrow(
      /Service "serviceInterface" is only defined as an interface placeholder/,
    );
  });

  test('dependency not found error', () => {
    class ServiceWithMissingDep implements Service<TestContext> {
      static deps = ['nonExistent'] as const;

      constructor(
        public _params: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: { nonExistent: Service<TestContext> },
      ) {}

      postInstantiate() {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        broken: ServiceWithMissingDep,
      },
    );

    expect(() => {
      container.instantiateAll();
    }).toThrowError(
      'Service "broken" failed during instantiate: Missing dependency "nonExistent"',
    );

    expect(container.describe().failedSlot).toMatchObject({
      phase: 'instantiate',
      slotId: 'broken',
    });
  });

  test('interface-typed slots accept a conforming implementation', () => {
    interface IServiceX extends Service<any> {
      method(): string;
    }

    class ConcreteServiceX implements IServiceX {
      constructor(
        public _: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
      method() {
        return 'concrete';
      }
      postInstantiate(): void {}
    }

    const implementation: ServiceToConstructor<IServiceX> = ConcreteServiceX;

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceX: implementation,
      },
    );

    const services = container.instantiateAll();

    expect(services.serviceX.method()).toBe('concrete');
    expectType<IServiceX, typeof services.serviceX>(services.serviceX);
  });

  test('mountAll should call mount on all services', async () => {
    class MountableServiceA implements Service<TestContext> {
      mount = vi.fn().mockResolvedValue(undefined);
      constructor(
        public contex: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    class MountableServiceB implements Service<TestContext> {
      mount = vi.fn().mockResolvedValue(undefined);
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: MountableServiceA,
        serviceB: MountableServiceB,
      },
    );

    const services = container.instantiateAll();
    await container.mountAll();
    expect(services.serviceA.mount).toHaveBeenCalled();
    expect(services.serviceB.mount).toHaveBeenCalled();
  });

  test('mountAll starts independent services in parallel', async () => {
    const mountEvents: string[] = [];
    let releaseA!: () => void;
    const serviceAReady = new Promise<void>((resolve) => {
      releaseA = resolve;
    });

    class MountableServiceA implements Service<TestContext> {
      async mount() {
        mountEvents.push('a:start');
        await serviceAReady;
        mountEvents.push('a:end');
      }
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    class MountableServiceB implements Service<TestContext> {
      async mount() {
        mountEvents.push('b:start');
      }
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: MountableServiceA,
        serviceB: MountableServiceB,
      },
    );

    container.instantiateAll();
    const mountPromise = container.mountAll();
    await Promise.resolve();

    expect(mountEvents).toEqual(['a:start', 'b:start']);

    releaseA();
    await mountPromise;

    expect(mountEvents).toEqual(['a:start', 'b:start', 'a:end']);
  });

  test('describe reports graph order and mounted count', async () => {
    class MountableServiceA implements Service<TestContext> {
      mounted = false;
      async mount() {
        this.mounted = true;
      }
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    class MountableServiceB implements Service<TestContext> {
      static deps = ['serviceA'] as const;
      mounted = false;
      async mount() {
        this.mounted = true;
      }
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: { serviceA: MountableServiceA },
      ) {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: MountableServiceA,
        serviceB: MountableServiceB,
      },
    );

    container.instantiateAll();
    await container.mountAll();

    expect(container.describe()).toMatchObject({
      dependencyOrder: ['serviceA', 'serviceB'],
      mountedCount: 2,
      services: [
        {
          slotId: 'serviceA',
          dependencies: [],
          instantiated: true,
          mounted: true,
        },
        {
          slotId: 'serviceB',
          dependencies: ['serviceA'],
          instantiated: true,
          mounted: true,
        },
      ],
    });
  });

  test('mountAll reports dependency mount failures on the failing slot', async () => {
    class FailingDependencyService implements Service<TestContext> {
      async mount() {
        throw new Error('dependency failed');
      }
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    class DependentService implements Service<TestContext> {
      static deps = ['dependency'] as const;
      async mount() {}
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: { dependency: FailingDependencyService },
      ) {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        dependency: FailingDependencyService,
        dependent: DependentService,
      },
    );

    container.instantiateAll();

    await expect(container.mountAll()).rejects.toThrow(
      'Service "dependency" failed during mount: dependency failed',
    );
    expect(container.describe().failedSlot).toMatchObject({
      phase: 'mount',
      slotId: 'dependency',
    });
  });

  test('mountAll should throw if instantiateAll has not been called', async () => {
    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: class {
          mount = vi.fn().mockResolvedValue(undefined);
          constructor(
            public context: {
              ctx: TestContext;
              serviceContext: ServiceContext;
            },
            public _deps: Record<string, never>,
          ) {}
        },
      },
    );

    await expect(container.mountAll()).rejects.toThrowError(
      'instantiateAll() must be called before mountAll().',
    );
  });

  test('mountAll should be idempotent', async () => {
    class MountableService implements Service<TestContext> {
      mount = vi.fn().mockResolvedValue(undefined);
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: MountableService,
      },
    );

    container.instantiateAll();
    await container.mountAll();
    await container.mountAll(); // should not throw or cause issues
  });

  test('static deps determine constructor parameters', () => {
    class TestServiceWithDeps implements Service<TestContext> {
      static deps = ['serviceA', 'serviceB'] as const;

      constructor(
        _params: { ctx: TestContext; serviceContext: ServiceContext },
        public readonly deps: { serviceA: ServiceA; serviceB: ServiceB },
      ) {}

      postInstantiate(): void {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceA: slot(ServiceA, () => ({ name: 'test' })),
        serviceB: ServiceB,
        testService: TestServiceWithDeps,
      },
    );

    const services = container.instantiateAll();

    expect(services.testService.deps.serviceA).toBe(services.serviceA);
    expect(services.testService.deps.serviceB).toBe(services.serviceB);
  });

  test('throws error when missing required dependency', () => {
    class ServiceWithMissingDep implements Service<TestContext> {
      static deps = ['nonExistent'] as const;

      constructor(
        public _params: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: { nonExistent: Service<TestContext> },
      ) {}

      postInstantiate(): void {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        broken: ServiceWithMissingDep,
      },
    );

    expect(() => {
      container.instantiateAll();
    }).toThrow(/Service "broken" failed during instantiate/);
  });

  test('startup errors include service slot and phase', async () => {
    class BadPostInstantiateService implements Service<TestContext> {
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}

      postInstantiate() {
        throw new Error('post failed');
      }
    }

    const postContainer = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        bad: BadPostInstantiateService,
      },
    );

    expect(() => postContainer.instantiateAll()).toThrow(ServiceStartupError);
    expect(postContainer.describe().failedSlot).toMatchObject({
      phase: 'postInstantiate',
      slotId: 'bad',
    });

    class BadMountService implements Service<TestContext> {
      constructor(
        public context: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: Record<string, never>,
      ) {}

      async mount() {
        throw new Error('mount failed');
      }
    }

    const mountContainer = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        bad: BadMountService,
      },
    );
    mountContainer.instantiateAll();

    await expect(mountContainer.mountAll()).rejects.toThrow(
      ServiceStartupError,
    );
    expect(mountContainer.describe().failedSlot).toMatchObject({
      phase: 'mount',
      slotId: 'bad',
    });
  });

  test('cyclic dependency detection', () => {
    class ServiceX implements Service<TestContext> {
      static deps = ['serviceY'] as const;
      constructor(
        public _params: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: { serviceY: ServiceY },
      ) {}
      postInstantiate(): void {}
    }

    class ServiceY implements Service<TestContext> {
      static deps = ['serviceX'] as const;
      constructor(
        public _params: { ctx: TestContext; serviceContext: ServiceContext },
        public _deps: { serviceX: ServiceX },
      ) {}

      postInstantiate(): void {}
    }

    const container = new Container(
      { context: { env: 'test' }, abortSignal: new AbortController().signal },
      {
        serviceX: ServiceX,
        serviceY: ServiceY,
      },
    );

    expect(() => {
      container.instantiateAll();
    }).toThrow(/Cycle detected/);
  });
});

describe('recursiveInstantiate', () => {
  test('basic instantiation with no dependencies', () => {
    const deps = [
      {
        name: 'serviceA',
        dependencies: [],
        create: () => ({ value: 'A' }),
      },
    ];

    const result = recursiveInstantiate(deps);
    expect(result.serviceA).toEqual({ value: 'A' });
  });

  test('instantiation with simple dependencies', () => {
    const deps = [
      {
        name: 'serviceA',
        dependencies: [],
        create: () => ({ value: 'A' }),
      },
      {
        name: 'serviceB',
        dependencies: ['serviceA'],
        create: (deps) => ({ value: 'B', depA: deps.serviceA }),
      },
    ];

    const result = recursiveInstantiate(deps);
    expect(result.serviceA).toEqual({ value: 'A' });
    expect(result.serviceB.value).toBe('B');
    expect(result.serviceB.depA).toBe(result.serviceA);
  });

  test('throws on cyclic dependencies', () => {
    const deps = [
      {
        name: 'serviceA',
        dependencies: ['serviceB'],
        create: () => ({ value: 'A' }),
      },
      {
        name: 'serviceB',
        dependencies: ['serviceA'],
        create: () => ({ value: 'B' }),
      },
    ];

    expect(() => recursiveInstantiate(deps)).toThrow(/Cycle detected/);
  });

  test('throws on missing dependency', () => {
    const deps = [
      {
        name: 'serviceA',
        dependencies: ['nonExistent'],
        create: () => ({ value: 'A' }),
      },
    ];

    expect(() => recursiveInstantiate(deps)).toThrow(
      /No dependency definition found for 'nonExistent'/,
    );
  });

  test('complex dependency tree resolution', () => {
    const deps = [
      {
        name: 'serviceA',
        dependencies: [],
        create: () => ({ value: 'A' }),
      },
      {
        name: 'serviceB',
        dependencies: ['serviceA'],
        create: (deps: any) => ({ value: 'B', depA: deps.serviceA }),
      },
      {
        name: 'serviceC',
        dependencies: ['serviceB'],
        create: (deps: any) => ({ value: 'C', depB: deps.serviceB }),
      },
      {
        name: 'serviceD',
        dependencies: ['serviceA', 'serviceC'],
        create: (deps: any) => ({
          value: 'D',
          depA: deps.serviceA,
          depC: deps.serviceC,
        }),
      },
    ];

    const result = recursiveInstantiate(deps);
    expect(result.serviceD?.depA).toBe(result.serviceA);
    expect(result.serviceD?.depC).toBe(result.serviceC);
    expect(result.serviceC?.depB).toBe(result.serviceB);
    expect(result.serviceB?.depA).toBe(result.serviceA);
  });

  test('handles independent services', () => {
    const deps = [
      {
        name: 'serviceA',
        dependencies: [],
        create: () => ({ value: 'A' }),
      },
      {
        name: 'serviceB',
        dependencies: [],
        create: () => ({ value: 'B' }),
      },
    ];

    const result = recursiveInstantiate(deps);
    expect(result.serviceA).toEqual({ value: 'A' });
    expect(result.serviceB).toEqual({ value: 'B' });
  });
});
