export type ConfigErrorCode = 'missing' | 'invalid'

export class ConfigError extends Error {
  readonly key: string
  readonly code: ConfigErrorCode

  constructor(key: string, code: ConfigErrorCode) {
    super(`Configuration ${code}: ${key}`)
    this.name = 'ConfigError'
    this.key = key
    this.code = code
  }
}

export interface ConfigField<T> {
  parse(raw: string): T
  optional?: boolean
  default?: T
}

export type ConfigSchema = Record<string, ConfigField<unknown>>

type FieldValue<F> = F extends ConfigField<infer T>
  ? F extends { optional: true }
    ? T | undefined
    : T
  : never

export type ConfigResult<S extends ConfigSchema> = {
  [K in keyof S]: FieldValue<S[K]>
}

export function defineConfig<S extends ConfigSchema>(schema: S): S {
  return schema
}

export function readConfig<S extends ConfigSchema>(
  schema: S,
  env: Record<string, string | undefined>,
): ConfigResult<S> {
  const output: Record<string, unknown> = {}

  for (const [key, field] of Object.entries(schema)) {
    const raw = env[key]
    if (raw === undefined || raw === '') {
      if (Object.prototype.hasOwnProperty.call(field, 'default')) {
        output[key] = field.default
        continue
      }
      if (field.optional) {
        output[key] = undefined
        continue
      }
      throw new ConfigError(key, 'missing')
    }

    try {
      output[key] = field.parse(raw)
    } catch {
      // Parser exceptions are deliberately discarded. A custom parser can include the raw
      // configuration value in its message, cause, or properties; retaining it would turn
      // ConfigError into a secret-exfiltration channel.
      throw new ConfigError(key, 'invalid')
    }
  }

  return output as ConfigResult<S>
}

export interface ConfigFieldOptions<T> {
  optional?: boolean
  default?: T
}

function field<T, O extends ConfigFieldOptions<T> | undefined>(
  parse: (raw: string) => T,
  options?: O,
): ConfigField<T> & (O extends { optional: true } ? { optional: true } : object) {
  return {
    parse,
    ...(options ?? {}),
  } as ConfigField<T> & (O extends { optional: true } ? { optional: true } : object)
}

export const configField = {
  string<O extends ConfigFieldOptions<string> | undefined = undefined>(options?: O) {
    return field((raw: string) => raw, options)
  },

  number<O extends ConfigFieldOptions<number> | undefined = undefined>(options?: O) {
    return field((raw: string) => {
      const value = Number(raw)
      if (!Number.isFinite(value)) throw new TypeError('Expected a finite number')
      return value
    }, options)
  },

  boolean<O extends ConfigFieldOptions<boolean> | undefined = undefined>(options?: O) {
    return field((raw: string) => {
      if (raw === 'true') return true
      if (raw === 'false') return false
      throw new TypeError("Expected exactly 'true' or 'false'")
    }, options)
  },

  url<O extends ConfigFieldOptions<string> | undefined = undefined>(options?: O) {
    return field((raw: string) => new URL(raw).toString(), options)
  },
}
