import type {
  AnyToken,
  AsyncServiceFactory,
  AsyncToken,
  CreateScopeOptions,
  DisposeCallback,
  Lifetime,
  ServiceContext,
  ServiceFactory,
  SyncToken,
} from './types.js'

/**
 * Thrown when a method is called on an injector that has already been disposed.
 */
export class InjectorDisposedError extends Error {
  constructor() {
    super('Injector already disposed')
    this.name = 'InjectorDisposedError'
  }
}

/**
 * Thrown when a factory depends on itself (directly or transitively) during
 * instantiation.
 */
export class CircularDependencyError extends Error {
  constructor(public readonly path: readonly string[]) {
    super(`Circular dependency detected: ${path.join(' -> ')}`)
    this.name = 'CircularDependencyError'
  }
}

/**
 * Thrown when a singleton-lifetime service attempts to depend on a non-singleton
 * service, or a scoped service attempts to depend on a transient.
 */
export class InvalidLifetimeDependencyError extends Error {
  constructor(
    public readonly parentName: string,
    public readonly parentLifetime: Lifetime,
    public readonly childName: string,
    public readonly childLifetime: Lifetime,
  ) {
    super(
      `Service '${parentName}' (${parentLifetime}) cannot depend on '${childName}' (${childLifetime}): ${parentLifetime} factories may only depend on compatible lifetimes.`,
    )
    this.name = 'InvalidLifetimeDependencyError'
  }
}

/**
 * Thrown when attempting to resolve an async token via the synchronous
 * {@link Injector.get} method. In well-typed call sites this case is caught at
 * compile time by {@link Injector.get}'s signature; the runtime check exists
 * as a defense for dynamically-constructed tokens.
 */
export class AsyncTokenInSyncContextError extends Error {
  constructor(public readonly tokenName: string) {
    super(`Service '${tokenName}' is async. Resolve it via injector.getAsync() instead of injector.get().`)
    this.name = 'AsyncTokenInSyncContextError'
  }
}

type CacheEntry =
  | { status: 'resolved'; value: unknown }
  | { status: 'pending'; promise: Promise<unknown> }
  | { status: 'failed'; error: unknown }

type AnyFactory<TService> = ServiceFactory<TService> | AsyncServiceFactory<TService>

const isParentCompatible = (parent: Lifetime, child: Lifetime): boolean => {
  switch (parent) {
    case 'singleton':
      return child === 'singleton'
    case 'scoped':
      return child === 'singleton' || child === 'scoped'
    case 'transient':
      return true
    default:
      return false
  }
}

/**
 * The dependency injection container. Created via {@link createInjector} or
 * {@link Injector.createScope}. Manages service resolution, caching, and
 * disposal across a hierarchical scope tree.
 */
export class Injector implements AsyncDisposable {
  public readonly parent: Injector | null
  public readonly owner: unknown
  private readonly cache = new Map<symbol, CacheEntry>()
  private readonly bindings = new Map<symbol, AnyFactory<unknown>>()
  private readonly disposeCallbacks: DisposeCallback[] = []
  private isDisposed = false

  constructor(options?: { parent?: Injector; owner?: unknown }) {
    this.parent = options?.parent ?? null
    this.owner = options?.owner
  }

  private ensureLive(): void {
    if (this.isDisposed) {
      throw new InjectorDisposedError()
    }
  }

  private rootInjector(): Injector {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    let current: Injector = this
    while (current.parent) {
      current = current.parent
    }
    return current
  }

  private ownerForLifetime(lifetime: Lifetime): Injector {
    switch (lifetime) {
      case 'singleton':
        return this.rootInjector()
      case 'scoped':
      case 'transient':
        return this
      default:
        return this
    }
  }

  private findCached(token: AnyToken<unknown>): { injector: Injector; entry: CacheEntry } | null {
    // Cache is owned by the lifetime resolver: singletons live at the root,
    // scoped tokens live at the requesting scope. Walking every ancestor
    // would surface a cached `null` from an ancestor that resolved a scoped
    // token with its default factory -- masking any descendant `bind()`
    // that rebinds the same token on a child scope.
    const owning = this.ownerForLifetime(token.lifetime)
    const entry = owning.cache.get(token.id)
    return entry ? { injector: owning, entry } : null
  }

  private findFactory<TService>(token: AnyToken<TService>): AnyFactory<TService> {
    const owning = this.ownerForLifetime(token.lifetime)
    const bound = owning.bindings.get(token.id)
    if (bound) {
      return bound as AnyFactory<TService>
    }
    return token.factory
  }

  private buildContext(token: AnyToken<unknown>, owningInjector: Injector, resolving: Set<symbol>): ServiceContext {
    const { lifetime, name: parentName } = token
    const inject = <TService>(depToken: SyncToken<TService>): TService => {
      if (!isParentCompatible(lifetime, depToken.lifetime)) {
        throw new InvalidLifetimeDependencyError(parentName, lifetime, depToken.name, depToken.lifetime)
      }
      return owningInjector.resolveSync<TService>(depToken, resolving)
    }
    const injectAsync = <TService>(depToken: AnyToken<TService>): Promise<TService> => {
      if (!isParentCompatible(lifetime, depToken.lifetime)) {
        throw new InvalidLifetimeDependencyError(parentName, lifetime, depToken.name, depToken.lifetime)
      }
      return owningInjector.resolveAsync<TService>(depToken, resolving)
    }
    const onDispose = (cb: DisposeCallback): void => {
      owningInjector.disposeCallbacks.push(cb)
    }
    return {
      inject,
      injectAsync,
      injector: owningInjector,
      token,
      onDispose,
    }
  }

  /**
   * Resolves a sync token. Throws {@link InjectorDisposedError} if the
   * injector is already disposed and {@link AsyncTokenInSyncContextError} if
   * a runtime-async token slips past the compile-time check.
   */
  public get<TService>(token: SyncToken<TService>): TService {
    this.ensureLive()
    if (token.isAsync) {
      throw new AsyncTokenInSyncContextError(token.name)
    }
    return this.resolveSync<TService>(token, new Set<symbol>())
  }

  /**
   * Resolves a sync or async token. Sync tokens are wrapped in a resolved
   * promise. Synchronous failures (disposed injector, sync-throwing async
   * factory) are normalised to a rejected promise so callers can rely on
   * `.rejects` / `.catch` uniformly.
   */
  public getAsync<TService>(token: AnyToken<TService>): Promise<TService> {
    try {
      this.ensureLive()
      if (!token.isAsync) {
        return Promise.resolve(this.resolveSync<TService>(token, new Set<symbol>()))
      }
      return this.resolveAsync<TService>(token, new Set<symbol>())
    } catch (error) {
      // Normalise synchronous errors (e.g. a disposed injector or an async
      // factory that sync-throws before returning a promise) into a rejected
      // promise so callers can always rely on `.rejects` / `.catch`.
      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for `prefer-promise-reject-errors`; `error` is `unknown` from catch
      return Promise.reject(error as Error)
    }
  }

  private resolveSync<TService>(token: SyncToken<TService>, resolving: Set<symbol>): TService {
    const existing = this.findCached(token)
    if (existing) {
      return this.consumeCached<TService>(existing.entry, token)
    }

    const owning = this.ownerForLifetime(token.lifetime)
    if (token.lifetime !== 'transient' && owning !== this) {
      return owning.resolveSync<TService>(token, resolving)
    }

    return owning.instantiateSync<TService>(token, resolving)
  }

  private resolveAsync<TService>(token: AnyToken<TService>, resolving: Set<symbol>): Promise<TService> {
    // Cycle check BEFORE the cache lookup: if the current resolution chain
    // is already waiting on this token, a cached `pending` entry would just
    // return its own in-flight promise and deadlock. Raising the cycle
    // error here surfaces the real problem instead.
    if (resolving.has(token.id)) {
      const path = [...Array.from(resolving).map((id) => id.description ?? '<anonymous>'), token.name]
      return Promise.reject(new CircularDependencyError(path))
    }

    const existing = this.findCached(token)
    if (existing) {
      return this.consumeCachedAsync<TService>(existing.entry, token)
    }

    const owning = this.ownerForLifetime(token.lifetime)
    if (token.lifetime !== 'transient' && owning !== this) {
      return owning.resolveAsync<TService>(token, resolving)
    }

    return owning.instantiateAsync<TService>(token, resolving)
  }

  private consumeCached<TService>(entry: CacheEntry, token: AnyToken<TService>): TService {
    if (entry.status === 'resolved') {
      return entry.value as TService
    }
    if (entry.status === 'failed') {
      throw entry.error
    }
    throw new AsyncTokenInSyncContextError(token.name)
  }

  private async consumeCachedAsync<TService>(entry: CacheEntry, _token: AnyToken<TService>): Promise<TService> {
    if (entry.status === 'resolved') {
      return entry.value as TService
    }
    if (entry.status === 'failed') {
      throw entry.error
    }
    return entry.promise as Promise<TService>
  }

  private pushResolving(resolving: Set<symbol>, token: AnyToken<unknown>): void {
    if (resolving.has(token.id)) {
      const path = [...Array.from(resolving).map((id) => id.description ?? '<anonymous>'), token.name]
      throw new CircularDependencyError(path)
    }
    resolving.add(token.id)
  }

  private popResolving(resolving: Set<symbol>, token: AnyToken<unknown>): void {
    resolving.delete(token.id)
  }

  private instantiateSync<TService>(token: SyncToken<TService>, resolving: Set<symbol>): TService {
    this.pushResolving(resolving, token)
    const ctx = this.buildContext(token, this, resolving)
    const factory = this.findFactory<TService>(token) as ServiceFactory<TService>
    try {
      const value = factory(ctx)
      if (token.lifetime !== 'transient') {
        this.cache.set(token.id, { status: 'resolved', value })
      }
      return value
    } catch (error) {
      if (token.lifetime !== 'transient') {
        this.cache.set(token.id, { status: 'failed', error })
      }
      throw error
    } finally {
      this.popResolving(resolving, token)
    }
  }

  private instantiateAsync<TService>(token: AnyToken<TService>, resolving: Set<symbol>): Promise<TService> {
    this.pushResolving(resolving, token)
    const ctx = this.buildContext(token, this, resolving)
    const factory = this.findFactory<TService>(token) as AsyncServiceFactory<TService>
    let promise: Promise<TService>
    try {
      promise = factory(ctx)
    } catch (error) {
      this.popResolving(resolving, token)
      if (token.lifetime !== 'transient') {
        this.cache.set(token.id, { status: 'failed', error })
      }
      throw error
    }

    if (token.lifetime !== 'transient') {
      this.cache.set(token.id, { status: 'pending', promise })
    }

    // Keep the token in `resolving` until the promise settles so that cycles
    // formed across async boundaries (A awaits B awaits A) are caught by
    // `pushResolving` on re-entry instead of deadlocking on a pending cache entry.
    return promise.then(
      (value) => {
        this.popResolving(resolving, token)
        if (token.lifetime !== 'transient') {
          this.cache.set(token.id, { status: 'resolved', value })
        }
        return value
      },
      (error: unknown) => {
        this.popResolving(resolving, token)
        if (token.lifetime !== 'transient') {
          this.cache.set(token.id, { status: 'failed', error })
        }
        throw error
      },
    )
  }

  /**
   * Installs a factory override for `token` on the injector that would own its
   * cached instance (root for singleton, this injector for scoped/transient).
   * Any cached entry for the token on that injector is dropped so the next
   * resolution uses the new factory.
   *
   * Scope caveat: a `scoped` bind applies only to the injector it was called
   * on. Descendant scopes each own their own cache and resolve scoped tokens
   * against `token.factory` unless they also call `bind`. Bind at the highest
   * scope whose cache you want the override to populate — usually the same
   * scope that will call `injector.get(token)`.
   */
  public bind<TService, TLifetime extends Lifetime>(
    token: SyncToken<TService, TLifetime>,
    factory: ServiceFactory<TService>,
  ): void
  public bind<TService, TLifetime extends Lifetime>(
    token: AsyncToken<TService, TLifetime>,
    factory: AsyncServiceFactory<TService>,
  ): void
  public bind<TService>(token: AnyToken<TService>, factory: AnyFactory<TService>): void {
    this.ensureLive()
    const owning = this.ownerForLifetime(token.lifetime)
    owning.bindings.set(token.id, factory)
    owning.cache.delete(token.id)
  }

  /**
   * Drops any cached entry for `token` on the injector that owns its cached
   * instance. The next resolution will run the factory again. Useful for
   * recovering from cached factory failures or resetting state between tests.
   */
  public invalidate<TService>(token: AnyToken<TService>): void {
    this.ensureLive()
    const owning = this.ownerForLifetime(token.lifetime)
    owning.cache.delete(token.id)
  }

  /**
   * Returns `true` when `token` has a cache entry (resolved, pending or
   * failed) on the scope that owns its lifetime -- the root injector for
   * singletons, the requesting injector for scoped tokens. Transient
   * tokens are never cached and therefore always report `false`.
   *
   * Useful for bootstrap helpers that must run before a service is first
   * resolved: checking `isResolved` lets them fail loudly instead of
   * silently leaking the previous instance.
   */
  public isResolved<TService>(token: AnyToken<TService>): boolean {
    this.ensureLive()
    return this.findCached(token) !== null
  }

  /**
   * Creates a child injector. The child has its own cache and bindings;
   * singleton resolution still walks up to the root. Disposing the child
   * leaves the parent untouched. Disposing the parent disposes all
   * descendants reachable through stored references.
   */
  public createScope(options?: CreateScopeOptions): Injector {
    this.ensureLive()
    return new Injector({ parent: this, owner: options?.owner })
  }

  /**
   * Disposes the injector: runs registered `onDispose` callbacks in LIFO
   * order, clears the cache, and marks the injector as disposed. Idempotent —
   * a second call is a no-op so `await using` and manual teardown paths
   * don't have to guard. Errors from callbacks are collected and re-thrown
   * as a single `AggregateError`.
   */
  public async [Symbol.asyncDispose](): Promise<void> {
    if (this.isDisposed) {
      return
    }
    this.isDisposed = true

    const callbacks = this.disposeCallbacks.splice(0).reverse()
    const errors: unknown[] = []
    for (const cb of callbacks) {
      try {
        await cb()
      } catch (error) {
        errors.push(error)
      }
    }
    this.cache.clear()
    this.bindings.clear()
    if (errors.length > 0) {
      throw new AggregateError(errors, `Errors thrown during injector disposal (${errors.length})`)
    }
  }
}

/**
 * Creates a new root {@link Injector}.
 */
export const createInjector = (): Injector => new Injector()

/**
 * Creates a child scope of `parent`, runs `fn` with it, then disposes the
 * scope — including when `fn` throws. Returns `fn`'s resolved value.
 */
export const withScope = async <TResult>(
  parent: Injector,
  fn: (scope: Injector) => Promise<TResult> | TResult,
  options?: CreateScopeOptions,
): Promise<TResult> => {
  const scope = parent.createScope(options)
  try {
    return await fn(scope)
  } finally {
    await scope[Symbol.asyncDispose]()
  }
}
