import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'

interface AuthentikGroup {
  pk: string
  name: string
  attributes?: Record<string, unknown>
}

// Thin wrapper around the Authentik API for the operations the provisioning
// service needs. We never expose raw Authentik errors to API callers — they
// surface as provisioningErrors.authentik strings.
@Injectable()
export class AuthentikClient {
  private readonly logger = new Logger(AuthentikClient.name)
  private readonly base: string
  private readonly token: string

  constructor(config: ConfigService) {
    this.base = config.getOrThrow<string>('AUTHENTIK_API_URL')
    this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
  }

  // Public Authentik origin (no /api/v3) — for building user-facing OIDC URLs
  // like the per-app issuer / .well-known discovery document.
  get publicBase(): string {
    return this.base.replace(/\/api\/v3\/?$/, '')
  }

  private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
    const res = await fetch(`${this.base}${path}`, {
      ...init,
      headers: {
        Authorization: `Bearer ${this.token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
        ...(init.headers ?? {}),
      },
    })
    if (!res.ok) {
      const body = await res.text().catch(() => '')
      throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`)
    }
    // 204 No Content (and other empty-body successes) crash res.json().
    // Endpoints like /core/groups/:id/add_user/ return 204; callers with a
    // void return type don't care about the payload, so hand back undefined.
    if (res.status === 204 || res.headers.get('content-length') === '0') {
      return undefined as T
    }
    return (await res.json()) as T
  }

  // Idempotent: returns existing group if name is taken, creates otherwise.
  async ensureGroup(slug: string, attributes: Record<string, unknown> = {}): Promise<AuthentikGroup> {
    const search = await this.request<{ results: AuthentikGroup[] }>(
      `/core/groups/?name=${encodeURIComponent(slug)}`,
    )
    if (search.results.length > 0) {
      this.logger.log(`Authentik group "${slug}" already exists (pk=${search.results[0].pk})`)
      return search.results[0]
    }
    const created = await this.request<AuthentikGroup>('/core/groups/', {
      method: 'POST',
      body: JSON.stringify({
        name: slug,
        attributes: { role: 'tenant', slug, ...attributes },
      }),
    })
    this.logger.log(`Created Authentik group "${slug}" (pk=${created.pk})`)
    return created
  }

  // Fully delete a user from Authentik (used when a member is removed from their
  // last tenant). 404 is tolerated so a re-run after a partial removal is safe.
  async deleteUser(userPk: number): Promise<void> {
    const res = await fetch(`${this.base}/core/users/${userPk}/`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${this.token}` },
    })
    if (!res.ok && res.status !== 404) {
      const body = await res.text().catch(() => '')
      throw new Error(`Authentik DELETE user ${userPk} → ${res.status}: ${body.slice(0, 200)}`)
    }
    this.logger.log(`Deleted Authentik user ${userPk}`)
  }

  // Enable / disable a user. is_active=false blocks all sign-in (portal, SSO,
  // and OCIS-via-SSO) without deleting anything — the basis of suspend/resume.
  async setUserActive(userPk: number, active: boolean): Promise<void> {
    await this.request(`/core/users/${userPk}/`, {
      method: 'PATCH',
      body: JSON.stringify({ is_active: active }),
    })
    this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
  }

  // Patch a user's identity / profile fields. `username` + `email` are kept
  // aligned by callers (our convention). `attributesMerge`, when given, is
  // read-modify-written so we don't clobber unrelated attributes — Authentik
  // PATCH replaces nested objects wholesale, so a naive `attributes: {...}`
  // would wipe e.g. the passwordExpired flag. No-op if nothing to change.
  async updateUser(
    userPk: number,
    patch: { username?: string; email?: string; name?: string; attributesMerge?: Record<string, unknown> },
  ): Promise<void> {
    const body: Record<string, unknown> = {}
    if (patch.username !== undefined) body.username = patch.username
    if (patch.email !== undefined) body.email = patch.email
    if (patch.name !== undefined) body.name = patch.name
    if (patch.attributesMerge && Object.keys(patch.attributesMerge).length > 0) {
      const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
        `/core/users/${userPk}/`,
      )
      body.attributes = { ...(user.attributes ?? {}), ...patch.attributesMerge }
    }
    if (Object.keys(body).length === 0) return
    await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify(body) })
    this.logger.log(`Updated Authentik user ${userPk} (${Object.keys(body).join(', ')})`)
  }

  // Force-logout: terminate the user's active sessions so they must sign in
  // again. Returns how many were terminated. We pass the `?user=` filter AND
  // re-filter client-side on the session's `user` pk — Authentik's endpoint
  // silently ignores an unknown query filter, which would otherwise return (and
  // delete) EVERY user's session. The client-side filter makes that impossible.
  async terminateSessions(userPk: number): Promise<number> {
    const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>(
      `/core/authenticated_sessions/?user=${userPk}`,
    )
    const sessions = (res.results ?? []).filter((s) => s.user === userPk)
    await Promise.all(
      sessions.map((s) =>
        fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, {
          method: 'DELETE',
          headers: { Authorization: `Bearer ${this.token}` },
        }).catch(() => {}),
      ),
    )
    this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`)
    return sessions.length
  }

  async deleteGroup(groupId: string): Promise<void> {
    const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${this.token}` },
    })
    if (!res.ok && res.status !== 404) {
      const body = await res.text().catch(() => '')
      throw new Error(`Authentik DELETE group ${groupId} → ${res.status}: ${body.slice(0, 200)}`)
    }
    this.logger.log(`Deleted Authentik group ${groupId}`)
  }

  // Pull a window of Authentik events. Used by the audit ingest worker.
  // `since` filters by created timestamp (strict greater-than); pagination is
  // forward-only via `page`. Authentik's default page size is 100.
  async listEvents(
    since?: Date,
    page = 1,
    pageSize = 100,
  ): Promise<AuthentikEventPage> {
    const params = new URLSearchParams({
      ordering: 'created',
      page: String(page),
      page_size: String(pageSize),
    })
    if (since) params.set('created__gt', since.toISOString())
    return this.request<AuthentikEventPage>(`/events/events/?${params}`)
  }

  // Look up a user by email. Returns undefined if not found. Used by the
  // invite flow so we can give a friendly conflict error instead of letting
  // Authentik 400.
  async findUserByEmail(email: string): Promise<AuthentikUser | undefined> {
    const res = await this.request<{ results: AuthentikUser[] }>(
      `/core/users/?email=${encodeURIComponent(email)}`,
    )
    return res.results[0]
  }

  // Create a user. Authentik's `uid` field becomes the JWT `sub` claim once
  // they log in — this is the same value our User.authentikSubjectId is keyed
  // on. We set type='internal' (real human user, not service account) and
  // is_active=true so the recovery link they receive lets them set a password.
  async createUser(input: {
    username: string
    email: string
    name: string
    attributes?: Record<string, unknown>
    groupPks?: string[]
  }): Promise<AuthentikUser> {
    const created = await this.request<AuthentikUser>('/core/users/', {
      method: 'POST',
      body: JSON.stringify({
        username: input.username,
        email: input.email,
        name: input.name,
        type: 'internal',
        is_active: true,
        path: 'users',
        groups: input.groupPks ?? [],
        attributes: input.attributes ?? {},
      }),
    })
    this.logger.log(`Created Authentik user ${input.email} (pk=${created.pk}, uid=${created.uid})`)
    return created
  }

  // Add an existing user to a group by ID. Idempotent — adding twice is a
  // no-op on Authentik's side.
  async addUserToGroup(userPk: number, groupId: string): Promise<void> {
    await this.request(`/core/groups/${groupId}/add_user/`, {
      method: 'POST',
      body: JSON.stringify({ pk: userPk }),
    })
    this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`)
  }

  // Remove a user from a group by ID. Authentik 204s even if the user wasn't
  // a member, so this is effectively idempotent.
  async removeUserFromGroup(userPk: number, groupId: string): Promise<void> {
    await this.request(`/core/groups/${groupId}/remove_user/`, {
      method: 'POST',
      body: JSON.stringify({ pk: userPk }),
    })
    this.logger.log(`Removed user ${userPk} from Authentik group ${groupId}`)
  }

  // Count a user's configured authenticators (TOTP / WebAuthn / static). Used
  // to surface an "MFA enrolled" badge on the partner team list — callers treat
  // a count > 0 as enrolled. Authentik has no single "all devices" admin route;
  // it exposes one per device type, so we query the common three and sum. Each
  // returns a paginated { results } envelope.
  async countAuthenticators(userPk: number): Promise<number> {
    const types = ['totp', 'webauthn', 'static']
    const counts = await Promise.all(
      types.map(async (t) => {
        try {
          const res = await this.request<{ results?: unknown[] }>(
            `/authenticators/admin/${t}/?user=${userPk}`,
          )
          return Array.isArray(res?.results) ? res.results.length : 0
        } catch {
          // A device type not enabled on this Authentik instance returns 404 —
          // don't let it zero out the types that do resolve.
          return 0
        }
      }),
    )
    return counts.reduce((a, b) => a + b, 0)
  }

  // Generate a single-use recovery link the new user clicks to set their
  // password + enroll MFA. Requires a "recovery flow" configured on the
  // Authentik brand — if not set, returns undefined so callers can fall
  // back to setInitialPassword.
  async recoveryLink(userPk: number): Promise<string | undefined> {
    try {
      const res = await this.request<{ link: string }>(
        `/core/users/${userPk}/recovery/`,
        { method: 'POST' },
      )
      return res.link
    } catch (err) {
      // Authentik returns 400 with "No recovery flow set." when the brand has
      // no recovery flow wired. Treat as soft-fail; caller fallback path
      // sets an initial password instead.
      if (err instanceof Error && err.message.includes('recovery flow')) {
        this.logger.warn('Authentik recovery link unavailable — no recovery flow configured')
        return undefined
      }
      throw err
    }
  }

  // Direct set_password — used when no recovery flow is configured. The
  // operator hands the temp password to the new user out-of-band; the user
  // changes it after first login via Authentik's password-change flow.
  // Authentik returns 204 No Content (empty body) on success, so we skip
  // request<T>()'s JSON parser and call fetch directly.
  async setInitialPassword(userPk: number, password: string): Promise<void> {
    const res = await fetch(`${this.base}/core/users/${userPk}/set_password/`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ password }),
    })
    if (!res.ok) {
      const body = await res.text().catch(() => '')
      throw new Error(`Authentik set_password ${userPk} → ${res.status}: ${body.slice(0, 200)}`)
    }
    this.logger.log(`Set initial password for Authentik user ${userPk}`)
  }

  // Mark the user's password as expired so Authentik forces a change at next
  // login. Used by the temp-password fallback path so a stolen temp password
  // can't outlive the first real session. The recovery-link path doesn't
  // need this — clicking the link runs through the recovery flow which sets
  // a fresh password anyway.
  async markPasswordExpired(userPk: number): Promise<void> {
    // Authentik stores per-user policy state under `attributes`. PATCH merges
    // top-level keys but replaces nested objects, so we have to read-modify-
    // write to avoid clobbering other attributes.
    const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
      `/core/users/${userPk}/`,
    )
    const attrs = {
      ...(user.attributes ?? {}),
      // Authentik's expiry check looks at `passwordExpired` (camelCase).
      passwordExpired: true,
      passwordExpiredAt: new Date().toISOString(),
    }
    await this.request(`/core/users/${userPk}/`, {
      method: 'PATCH',
      body: JSON.stringify({ attributes: attrs }),
    })
    this.logger.log(`Marked password as expired for Authentik user ${userPk}`)
  }

  // ── Recovery flow bootstrap ────────────────────────────────────────────
  // Default Authentik installs don't ship a recovery flow — operators have to
  // either create one in the admin UI or have us provision it. The methods
  // below make the bootstrap idempotent: re-running on an already-configured
  // Authentik is a no-op.

  async findRecoveryFlow(): Promise<AuthentikFlow | undefined> {
    const res = await this.request<{ results: AuthentikFlow[] }>(
      `/flows/instances/?designation=recovery`,
    )
    return res.results[0]
  }

  async getDefaultBrand(): Promise<AuthentikBrand | undefined> {
    const res = await this.request<{ results: AuthentikBrand[] }>('/core/brands/')
    return res.results.find((b) => b._default) ?? res.results[0]
  }

  async findStageByName(name: string): Promise<AuthentikStage | undefined> {
    const res = await this.request<{ results: AuthentikStage[] }>(
      `/stages/all/?name=${encodeURIComponent(name)}`,
    )
    return res.results.find((s) => s.name === name)
  }

  async createFlow(input: {
    name: string
    slug: string
    title: string
    designation: 'recovery' | 'authentication' | 'authorization' | 'enrollment' | 'invalidation' | 'stage_configuration' | 'unenrollment'
  }): Promise<AuthentikFlow> {
    return this.request<AuthentikFlow>('/flows/instances/', {
      method: 'POST',
      body: JSON.stringify({
        name: input.name,
        slug: input.slug,
        title: input.title,
        designation: input.designation,
        // 'none' = no extra auth required to start the flow (the recovery
        // token in the link is the auth). 'require_authenticated' would
        // break the link-click path.
        authentication: 'none',
        // No background; the consent screen is up to the brand.
        background: '',
      }),
    })
  }

  async bindStageToFlow(input: { target: string; stage: string; order: number }): Promise<void> {
    await this.request('/flows/bindings/', {
      method: 'POST',
      body: JSON.stringify({
        target: input.target,
        stage: input.stage,
        order: input.order,
        evaluate_on_plan: true,
        re_evaluate_policies: false,
      }),
    })
  }

  async setBrandRecoveryFlow(brandUuid: string, flowUuid: string): Promise<void> {
    await this.request(`/core/brands/${brandUuid}/`, {
      method: 'PATCH',
      body: JSON.stringify({ flow_recovery: flowUuid }),
    })
    this.logger.log(`Set brand ${brandUuid} flow_recovery → ${flowUuid}`)
  }

  // ── SSO apps: customer registers external apps using Dezky as the IdP ─────
  // We create an OAuth2/OIDC Provider + Application in Authentik and bind the
  // tenant's group to the application so only that workspace's members can use
  // it. Provider pk is a number; Application pk is a uuid (slug is the human id).

  // Find a flow pk by designation, preferring a slug substring (e.g. the
  // explicit-consent authorization flow) and falling back to the first match.
  async findFlowPk(designation: string, preferSlugIncludes?: string): Promise<string | undefined> {
    const res = await this.request<{ results: AuthentikFlow[] }>(
      `/flows/instances/?designation=${encodeURIComponent(designation)}`,
    )
    if (!res.results.length) return undefined
    if (preferSlugIncludes) {
      const pref = res.results.find((f) => f.slug.includes(preferSlugIncludes))
      if (pref) return pref.pk
    }
    return res.results[0].pk
  }

  async findSigningKeyPk(): Promise<string | undefined> {
    const res = await this.request<{ results: Array<{ pk: string; private_key_available?: boolean }> }>(
      `/crypto/certificatekeypairs/?has_key=true`,
    )
    return res.results.find((k) => k.private_key_available)?.pk ?? res.results[0]?.pk
  }

  // The three standard OIDC scope mappings, resolved by Authentik's stable
  // `managed` identifiers (pks differ per instance).
  async findOidcScopeMappingPks(): Promise<string[]> {
    const res = await this.request<{ results: Array<{ pk: string; managed?: string }> }>(
      `/propertymappings/provider/scope/`,
    )
    const wanted = new Set([
      'goauthentik.io/providers/oauth2/scope-openid',
      'goauthentik.io/providers/oauth2/scope-email',
      'goauthentik.io/providers/oauth2/scope-profile',
    ])
    return res.results.filter((m) => m.managed && wanted.has(m.managed)).map((m) => m.pk)
  }

  async createOAuth2Provider(input: {
    name: string
    redirectUris: string[]
    clientType?: 'confidential' | 'public'
  }): Promise<{ pk: number; clientId: string; clientSecret: string }> {
    const [authorizationFlow, invalidationFlow, signingKey, scopeMappings] = await Promise.all([
      this.findFlowPk('authorization', 'explicit-consent'),
      this.findFlowPk('invalidation', 'provider-invalidation'),
      this.findSigningKeyPk(),
      this.findOidcScopeMappingPks(),
    ])
    if (!authorizationFlow) throw new Error('No Authentik authorization flow available')
    const body: Record<string, unknown> = {
      name: input.name,
      authorization_flow: authorizationFlow,
      client_type: input.clientType ?? 'confidential',
      redirect_uris: input.redirectUris.map((url) => ({ matching_mode: 'strict', url })),
      property_mappings: scopeMappings,
      sub_mode: 'hashed_user_id',
    }
    if (invalidationFlow) body.invalidation_flow = invalidationFlow
    if (signingKey) body.signing_key = signingKey
    const p = await this.request<{ pk: number; client_id: string; client_secret: string }>(
      '/providers/oauth2/',
      { method: 'POST', body: JSON.stringify(body) },
    )
    this.logger.log(`Created Authentik OAuth2 provider "${input.name}" (pk=${p.pk})`)
    return { pk: p.pk, clientId: p.client_id, clientSecret: p.client_secret }
  }

  async createApplication(input: {
    name: string
    slug: string
    providerPk: number
    group?: string
  }): Promise<{ pk: string; slug: string }> {
    const app = await this.request<{ pk: string; slug: string }>('/core/applications/', {
      method: 'POST',
      body: JSON.stringify({
        name: input.name,
        slug: input.slug,
        provider: input.providerPk,
        group: input.group ?? '',
      }),
    })
    this.logger.log(`Created Authentik application "${input.slug}" (pk=${app.pk})`)
    return { pk: app.pk, slug: app.slug }
  }

  // A binding with a group and no policy = allow that group. Scopes the app to
  // the tenant's workspace members.
  async bindGroupToApplication(appPk: string, groupPk: string): Promise<void> {
    await this.request('/policies/bindings/', {
      method: 'POST',
      body: JSON.stringify({ target: appPk, group: groupPk, order: 0, enabled: true }),
    })
  }

  async deleteApplication(slug: string): Promise<void> {
    const res = await fetch(`${this.base}/core/applications/${slug}/`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${this.token}` },
    })
    if (!res.ok && res.status !== 404) {
      const body = await res.text().catch(() => '')
      throw new Error(`Authentik DELETE application ${slug} → ${res.status}: ${body.slice(0, 200)}`)
    }
  }

  async deleteOAuth2Provider(pk: number): Promise<void> {
    const res = await fetch(`${this.base}/providers/oauth2/${pk}/`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${this.token}` },
    })
    if (!res.ok && res.status !== 404) {
      const body = await res.text().catch(() => '')
      throw new Error(`Authentik DELETE provider ${pk} → ${res.status}: ${body.slice(0, 200)}`)
    }
  }
}

export interface AuthentikFlow {
  pk: string
  slug: string
  name: string
  title: string
  designation: string
}

export interface AuthentikBrand {
  brand_uuid: string
  domain: string
  _default?: boolean
  flow_recovery?: string | null
}

export interface AuthentikStage {
  pk: string
  name: string
  component: string
}

export interface AuthentikUser {
  pk: number
  uid: string  // becomes JWT `sub` on first login
  username: string
  email: string
  name?: string
  is_active: boolean
  groups?: string[]
}

// Shape returned by /events/events/. Only the fields we read; Authentik
// includes a number of others (tenant, brand) we don't need.
export interface AuthentikEvent {
  pk: string
  action: string
  app?: string
  user?: { pk?: number; username?: string; name?: string; email?: string }
  context?: Record<string, unknown>
  client_ip?: string
  created: string
}
export interface AuthentikEventPage {
  pagination: { next: number; previous: number; count: number; current: number; total_pages: number; start_index: number; end_index: number }
  results: AuthentikEvent[]
}
