import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { OcisCredential, OcisCredentialDocument } from '../schemas/ocis-credential.schema.js'

// A libregraph quota object as returned on each drive. All byte counts are
// integers; `total` of 0 means "unlimited" in OCIS (the default in dev).
export interface OcisQuota {
  total?: number
  used?: number
  remaining?: number
  deleted?: number
  state?: string
}

// A libregraph drive. We only model the fields the storage summary needs.
export interface OcisDrive {
  id: string
  name?: string
  driveType?: string // 'personal' | 'project' | 'virtual' | 'mountpoint'
  quota?: OcisQuota
  owner?: { user?: { id?: string; displayName?: string } }
}

// A libregraph user. `mail` is the join key back to our Mongo User docs.
export interface OcisUser {
  id: string
  displayName?: string
  mail?: string
}

const CREDENTIAL_KEY = 'ocis-svc'

// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed.
// The READ layer is real: it lists per-drive quota for the customer-admin
// Storage page via the libregraph /graph/v1.0 API.
//
// Auth is the hard part. OCIS has no backend service-account/client-credentials
// grant and trusts exactly one issuer; basic auth doesn't resolve a user in our
// external-IdP setup. The only mechanism that works without reconfiguring SSO is
// a refresh-token bootstrap: a one-time interactive OIDC login of the service
// user (svc-platform-api) against the SAME provider OCIS trusts (`ocis-web`,
// per-provider issuer .../o/ocis/) yields a refresh token. We persist it and
// mint access tokens with the refresh_token grant from then on. Authentik
// rotates the refresh token on every use, so we persist the new one each time.
// The service user must hold the OCIS admin role (required to list all drives).
@Injectable()
export class OcisClient {
  private readonly logger = new Logger(OcisClient.name)
  private readonly base: string
  private readonly tokenUrl?: string
  private readonly clientId?: string
  private readonly scope: string

  // In-memory access-token cache (tokens live minutes). The refresh token lives
  // in Mongo because it rotates and must survive restarts.
  private accessToken?: string
  private accessExpiresAt = 0
  // Single-flight guard so concurrent storage requests don't race the rotating
  // refresh token (each refresh invalidates the previous one).
  private refreshing?: Promise<string>

  constructor(
    config: ConfigService,
    @InjectModel(OcisCredential.name) private readonly credModel: Model<OcisCredentialDocument>,
  ) {
    this.base = config.getOrThrow<string>('OCIS_API_URL')
    this.tokenUrl = config.get<string>('OCIS_OIDC_TOKEN_URL') || undefined
    this.clientId = config.get<string>('OCIS_OIDC_CLIENT_ID') || undefined
    // offline_access is what makes Authentik issue a refresh token on bootstrap.
    this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email offline_access'
  }

  // Static config present. Whether we actually have a bootstrapped refresh token
  // is determined per-call (DB lookup) — a missing token surfaces as the page's
  // "unavailable" state, not a startup failure.
  get configured(): boolean {
    return !!(this.tokenUrl && this.clientId)
  }

  // Seed/replace the refresh token (called by the one-time bootstrap).
  async setRefreshToken(refreshToken: string): Promise<void> {
    await this.credModel.updateOne(
      { key: CREDENTIAL_KEY },
      { $set: { refreshToken } },
      { upsert: true },
    )
    this.accessToken = undefined
    this.accessExpiresAt = 0
  }

  private async loadRefreshToken(): Promise<string> {
    const doc = await this.credModel.findOne({ key: CREDENTIAL_KEY }).lean().exec()
    if (!doc?.refreshToken) {
      throw new Error('OCIS service refresh token not bootstrapped — run the OCIS bootstrap')
    }
    return doc.refreshToken
  }

  private async getToken(): Promise<string> {
    if (this.accessToken && Date.now() < this.accessExpiresAt) return this.accessToken
    if (this.refreshing) return this.refreshing
    this.refreshing = this.refreshAccessToken().finally(() => {
      this.refreshing = undefined
    })
    return this.refreshing
  }

  // Exchange the stored refresh token for a fresh access token, persisting the
  // rotated refresh token Authentik hands back. Public client (ocis-web) → no
  // client secret. The resulting token's issuer matches OCIS_OIDC_ISSUER.
  private async refreshAccessToken(): Promise<string> {
    const refreshToken = await this.loadRefreshToken()
    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      client_id: this.clientId!,
      refresh_token: refreshToken,
      scope: this.scope,
    })
    const res = await fetch(this.tokenUrl!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
      body,
    })
    if (!res.ok) {
      const text = await res.text().catch(() => '')
      // A revoked/expired refresh token means a human must re-bootstrap.
      this.logger.warn(
        `OCIS token refresh → ${res.status}: ${text.slice(0, 160)} — re-bootstrap the OCIS service login if this persists`,
      )
      throw new Error(`OCIS token refresh failed (${res.status})`)
    }
    const json = (await res.json()) as {
      access_token?: string
      expires_in?: number
      refresh_token?: string
    }
    if (!json.access_token) throw new Error('OCIS token refresh returned no access_token')
    // Persist the rotated refresh token (Authentik rotates on every use).
    if (json.refresh_token && json.refresh_token !== refreshToken) {
      await this.credModel
        .updateOne({ key: CREDENTIAL_KEY }, { $set: { refreshToken: json.refresh_token } })
        .exec()
    }
    this.accessToken = json.access_token
    this.accessExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000
    return this.accessToken
  }

  private async request<T>(path: string): Promise<T> {
    const token = await this.getToken()
    const res = await fetch(`${this.base}${path}`, {
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    })
    if (!res.ok) {
      // Drop a possibly-stale access token so the next call re-refreshes.
      if (res.status === 401) {
        this.accessToken = undefined
        this.accessExpiresAt = 0
      }
      const body = await res.text().catch(() => '')
      throw new Error(`OCIS GET ${path} → ${res.status}: ${body.slice(0, 200)}`)
    }
    return (await res.json()) as T
  }

  // Write-capable variant of request() for POST/DELETE libregraph calls.
  private async mutate<T>(
    method: 'POST' | 'DELETE',
    path: string,
    body?: unknown,
  ): Promise<T | undefined> {
    const token = await this.getToken()
    const res = await fetch(`${this.base}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
        ...(body ? { 'Content-Type': 'application/json' } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    })
    if (!res.ok) {
      if (res.status === 401) {
        this.accessToken = undefined
        this.accessExpiresAt = 0
      }
      const text = await res.text().catch(() => '')
      throw new Error(`OCIS ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`)
    }
    if (res.status === 204 || res.headers.get('content-length') === '0') return undefined
    return (await res.json()) as T
  }

  // List all drives, optionally filtered with an OData $filter expression
  // (e.g. `driveType eq 'personal'`). Requires the OCIS admin role. libregraph
  // caps the page at 100 items; a tenant's personal drives stay well under that.
  async listDrives(filter?: string): Promise<OcisDrive[]> {
    if (!this.configured) return []
    const qs = filter ? `?$filter=${encodeURIComponent(filter)}` : ''
    const body = await this.request<{ value?: OcisDrive[] }>(`/graph/v1.0/drives${qs}`)
    return body.value ?? []
  }

  // List all OCIS users so we can map a drive's owner id back to an email and
  // join against our tenant membership.
  async listUsers(): Promise<OcisUser[]> {
    if (!this.configured) return []
    const body = await this.request<{ value?: OcisUser[] }>('/graph/v1.0/users')
    return body.value ?? []
  }

  // Proactively create the OCIS account so the user shows up immediately. OCIS
  // runs with PROXY_AUTOPROVISION_ACCOUNTS, so it ALSO creates the account (and
  // personal drive) on the user's first SSO login — this just does it up front.
  // `username` must match the OIDC claim OCIS keys on (preferred_username, which
  // is the Authentik username = the user's email here). Returns the libregraph
  // user id, or { deferred: true } if creation isn't available (external-IdP
  // setups can reject graph user-create) so the caller falls back to autoprovision.
  async ensureUser(input: {
    username: string
    displayName: string
    mail: string
  }): Promise<{ id?: string; deferred: boolean }> {
    if (!this.configured) return { deferred: true }
    try {
      const user = await this.mutate<{ id?: string }>('POST', '/graph/v1.0/users', {
        onPremisesSamAccountName: input.username,
        displayName: input.displayName,
        mail: input.mail,
        accountEnabled: true,
      })
      this.logger.log(`Created OCIS user ${input.mail} (id=${user?.id})`)
      return { id: user?.id, deferred: false }
    } catch (err) {
      this.logger.warn(
        `OCIS user pre-create unavailable for ${input.mail} (${(err as Error).message.slice(0, 120)}) — will auto-provision on first sign-in`,
      )
      return { deferred: true }
    }
  }

  async deleteUser(id: string): Promise<void> {
    if (!this.configured) return
    await this.mutate('DELETE', `/graph/v1.0/users/${id}`).catch((err) => {
      this.logger.error(`OCIS user delete failed (id=${id}): ${(err as Error).message}`)
    })
  }

  // ── Provisioning (stubbed) ────────────────────────────────────────────────
  // Real implementation needs POST /graph/v1.0/drives { name, driveType:
  // 'project' } to create a space and assign it to the tenant's group / users.
  async ensureSpace(slug: string): Promise<{ id: string }> {
    this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`)
    return { id: `stub-${slug}` }
  }

  async deleteSpace(spaceId: string): Promise<void> {
    this.logger.warn(`OCIS space delete is stubbed — would delete ${spaceId} at ${this.base}`)
  }
}
