import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { TenantSsoApp, type TenantSsoAppDocument } from '../schemas/tenant-sso-app.schema.js'
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
import type { CreateSsoAppDto } from './dto/create-sso-app.dto.js'

export interface SsoAppView {
  id: string
  name: string
  protocol: 'oidc' | 'saml'
  clientId?: string
  redirectUris: string[]
  issuer?: string
  wellKnownUrl?: string
  createdAt?: string
}

// Customer-managed SSO apps (Dezky as IdP). Creates an Authentik OAuth2 provider
// + application bound to the tenant's group; mirrors the refs in Mongo so the
// portal lists/deletes without re-querying Authentik.
@Injectable()
export class TenantSsoService {
  private readonly logger = new Logger(TenantSsoService.name)

  constructor(
    @InjectModel(TenantSsoApp.name) private readonly model: Model<TenantSsoAppDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    private readonly authentik: AuthentikClient,
    private readonly audit: AuditService,
  ) {}

  private toView(d: TenantSsoAppDocument): SsoAppView {
    const issuer = d.authentikAppSlug
      ? `${this.authentik.publicBase}/application/o/${d.authentikAppSlug}/`
      : undefined
    return {
      id: String(d._id),
      name: d.name,
      protocol: d.protocol,
      clientId: d.clientId,
      redirectUris: d.redirectUris ?? [],
      issuer,
      wellKnownUrl: issuer ? `${issuer}.well-known/openid-configuration` : undefined,
      createdAt: (d as TenantSsoAppDocument & { createdAt?: Date }).createdAt?.toISOString(),
    }
  }

  async list(tenant: TenantDocument): Promise<SsoAppView[]> {
    const docs = await this.model.find({ tenantId: tenant._id }).sort({ createdAt: -1 }).exec()
    return docs.map((d) => this.toView(d))
  }

  // Ensure the tenant has an Authentik group to scope apps to; provisions one
  // on demand for tenants created before group provisioning existed.
  private async ensureGroupPk(tenant: TenantDocument): Promise<string> {
    if (tenant.authentikGroupId) return tenant.authentikGroupId
    const group = await this.authentik.ensureGroup(tenant.slug)
    tenant.authentikGroupId = group.pk
    await tenant.save()
    return group.pk
  }

  private kebab(s: string): string {
    return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'app'
  }

  private async uniqueSlug(base: string): Promise<string> {
    let slug = base
    let n = 1
    while (await this.model.exists({ authentikAppSlug: slug })) {
      n += 1
      slug = `${base}-${n}`
    }
    return slug
  }

  async create(
    tenant: TenantDocument,
    dto: CreateSsoAppDto,
    actor?: AuditActor,
  ): Promise<SsoAppView & { clientSecret: string }> {
    const groupPk = await this.ensureGroupPk(tenant)
    const slug = await this.uniqueSlug(`dezky-${tenant.slug}-${this.kebab(dto.name)}`)

    // Create provider → application → group binding. On any failure, roll back
    // whatever was created so we don't leave orphaned Authentik objects.
    let providerPk: number | undefined
    let appCreated = false
    try {
      const provider = await this.authentik.createOAuth2Provider({
        name: `${tenant.name} · ${dto.name}`,
        redirectUris: dto.redirectUris,
      })
      providerPk = provider.pk
      const app = await this.authentik.createApplication({
        name: dto.name,
        slug,
        providerPk: provider.pk,
        group: tenant.slug,
      })
      appCreated = true
      await this.authentik.bindGroupToApplication(app.pk, groupPk)

      const doc = await this.model.create({
        tenantId: tenant._id,
        name: dto.name,
        protocol: 'oidc',
        clientId: provider.clientId,
        redirectUris: dto.redirectUris,
        authentikAppSlug: slug,
        authentikAppPk: app.pk,
        authentikProviderPk: provider.pk,
      })

      void this.audit.record(
        {
          action: 'tenant.sso_app_created',
          resourceType: 'tenant',
          resourceId: String(tenant._id),
          resourceName: tenant.name,
          tenantSlug: tenant.slug,
          metadata: { app: dto.name, slug },
        },
        actor,
      )

      // client_secret is returned once, never persisted.
      return { ...this.toView(doc), clientSecret: provider.clientSecret }
    } catch (err) {
      if (appCreated) await this.authentik.deleteApplication(slug).catch(() => {})
      if (providerPk != null) await this.authentik.deleteOAuth2Provider(providerPk).catch(() => {})
      throw err
    }
  }

  async remove(tenant: TenantDocument, id: string, actor?: AuditActor): Promise<void> {
    const doc = await this.model.findOne({ _id: id, tenantId: tenant._id }).exec()
    if (!doc) throw new NotFoundException('SSO app not found')

    if (doc.authentikAppSlug) await this.authentik.deleteApplication(doc.authentikAppSlug).catch((e) => {
      this.logger.warn(`SSO delete: app ${doc.authentikAppSlug} → ${e instanceof Error ? e.message : e}`)
    })
    if (doc.authentikProviderPk != null) await this.authentik.deleteOAuth2Provider(doc.authentikProviderPk).catch((e) => {
      this.logger.warn(`SSO delete: provider ${doc.authentikProviderPk} → ${e instanceof Error ? e.message : e}`)
    })
    await doc.deleteOne()

    void this.audit.record(
      {
        action: 'tenant.sso_app_deleted',
        resourceType: 'tenant',
        resourceId: String(tenant._id),
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
        metadata: { app: doc.name },
      },
      actor,
    )
  }
}
