import crypto from 'node:crypto'
import type { Request, Response } from 'express'
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'
import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
import {
  consumeOAuthCode, findOAuthToken, getOAuthClient, peekOAuthCode, revokeOAuthToken, saveOAuthClient, saveOAuthCode, saveOAuthTokens, type Db,
} from '@znet/catalog-db'
import type { Logger } from '@znet/shared'
import type { MagentoApi } from '@znet/magento'
import { LoginThrottle } from './throttle.js'
import { renderLoginPage } from './login-page.js'
import { grantedScopes, type UserScopes } from './user-scopes.js'

export interface MagentoAuthOptions {
  db: Db
  magento: MagentoApi
  log: Logger
  /** HMAC key for the signed login request carried through the form. */
  secret: string
  userScopes: UserScopes
  require2fa: boolean
  resource: URL
  accessTtlSeconds: number
  refreshTtlSeconds: number
  storeName: string
}

const CODE_TTL_MS = 5 * 60_000
const LOGIN_FORM_TTL_MS = 10 * 60_000

const hash = (value: string) => crypto.createHash('sha256').update(value).digest('hex')
const randomToken = () => crypto.randomBytes(32).toString('base64url')

/** Everything needed to finish an /authorize request after the login form is posted. */
interface PendingAuthorization {
  clientId: string
  clientName: string | null
  redirectUri: string
  state: string | null
  codeChallenge: string
  scopes: string[]
  resource: string | null
  exp: number
}

/**
 * OAuth 2.1 authorization server where staff sign in with their **Magento admin** username,
 * password and 2FA code. Magento is the source of truth for who is a staff member; this server
 * only issues its own short-lived MCP tokens. Passwords are never stored or logged.
 *
 * Flow: ChatGPT registers (DCR) → /authorize renders our login page → POST /login checks the
 * credentials against Magento → redirect with a one-time code → /token exchanges it (PKCE).
 */
export class MagentoAdminAuthProvider implements OAuthServerProvider {
  readonly throttle = new LoginThrottle()

  constructor(private readonly o: MagentoAuthOptions) {}

  get clientsStore(): OAuthRegisteredClientsStore {
    const { db } = this.o
    return {
      getClient: async clientId => (await getOAuthClient(db, clientId)) as OAuthClientInformationFull | undefined,
      registerClient: async client => {
        const full = client as OAuthClientInformationFull
        await saveOAuthClient(db, full.client_id, full as unknown as Record<string, unknown>)
        this.o.log.info({ clientId: full.client_id, clientName: full.client_name, redirectUris: full.redirect_uris }, 'oauth client registered')
        return full
      },
    }
  }

  // ── /authorize → login page ──────────────────────────────────────────────

  async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
    const pending: PendingAuthorization = {
      clientId: client.client_id,
      clientName: client.client_name ?? null,
      redirectUri: params.redirectUri,
      state: params.state ?? null,
      codeChallenge: params.codeChallenge,
      scopes: params.scopes ?? [],
      resource: params.resource?.href ?? null,
      exp: Date.now() + LOGIN_FORM_TTL_MS,
    }
    this.sendLoginPage(res, this.sign(pending), pending.clientName)
  }

  /** POST /login — check credentials against Magento and redirect back with a code. */
  async handleLogin(req: Request, res: Response): Promise<void> {
    const body = (req.body ?? {}) as Record<string, string | undefined>
    const pending = this.verify(body.request)
    if (!pending) {
      res.status(400).type('text').send('This sign-in link has expired. Go back to ChatGPT and connect again.')
      return
    }

    const username = (body.username ?? '').trim()
    const password = body.password ?? ''
    const otp = (body.otp ?? '').replace(/\s/g, '')
    const retry = (message: string, status = 401) => this.sendLoginPage(res, body.request!, pending.clientName, message, username, status)

    if (!username || !password || (this.o.require2fa && !/^\d{6}$/.test(otp))) {
      return retry(this.o.require2fa ? 'Enter your username, password and the 6-digit code from your authenticator app.' : 'Enter your username and password.', 400)
    }

    const throttleKey = `${req.ip}|${username.toLowerCase()}`
    const wait = this.throttle.blockedFor(throttleKey)
    if (wait) return retry(`Too many attempts. Try again in ${Math.ceil(wait / 60)} minute(s).`, 429)

    let ok = false
    try {
      ok = (await this.o.magento.verifyAdminLogin(username, password, this.o.require2fa ? otp : undefined)).ok
    } catch (err) {
      this.o.log.error({ err }, 'magento login check failed')
      return retry('Could not reach Magento to check your sign-in. Try again shortly.', 503)
    }

    if (!ok) {
      this.throttle.fail(throttleKey)
      this.o.log.warn({ username, ip: req.ip }, 'magento admin login rejected')
      return retry('Sign-in failed. Check your Magento admin username, password and code.')
    }
    this.throttle.succeed(throttleKey)

    const scopes = grantedScopes(this.o.userScopes, username, pending.scopes)
    if (!scopes.length) {
      this.o.log.warn({ username }, 'magento admin not allowed to use MCP')
      return retry('Your Magento account is not enabled for ChatGPT access. Ask an administrator.', 403)
    }

    const code = randomToken()
    await saveOAuthCode(this.o.db, {
      codeHash: hash(code),
      clientId: pending.clientId,
      subject: username.toLowerCase(),
      scopes,
      codeChallenge: pending.codeChallenge,
      redirectUri: pending.redirectUri,
      resource: pending.resource,
      expiresAt: new Date(Date.now() + CODE_TTL_MS),
    })
    this.o.log.info({ username, clientId: pending.clientId, scopes }, 'magento admin signed in')

    const target = new URL(pending.redirectUri)
    target.searchParams.set('code', code)
    if (pending.state) target.searchParams.set('state', pending.state)
    res.redirect(302, target.href)
  }

  // ── /token ───────────────────────────────────────────────────────────────

  async challengeForAuthorizationCode(client: OAuthClientInformationFull, code: string): Promise<string> {
    const row = await peekOAuthCode(this.o.db, hash(code), client.client_id)
    if (!row) throw new InvalidGrantError('Invalid or expired authorization code')
    return row.codeChallenge
  }

  async exchangeAuthorizationCode(client: OAuthClientInformationFull, code: string, _verifier?: string, redirectUri?: string): Promise<OAuthTokens> {
    // PKCE was already verified by the SDK token handler using challengeForAuthorizationCode.
    const row = await consumeOAuthCode(this.o.db, hash(code), client.client_id)
    if (!row) throw new InvalidGrantError('Invalid, expired or already used authorization code')
    if (redirectUri && redirectUri !== row.redirectUri) throw new InvalidGrantError('redirect_uri does not match the authorization request')
    return this.issueTokens(client.client_id, row.subject, row.scopes)
  }

  async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
    const row = await findOAuthToken(this.o.db, hash(refreshToken), 'refresh')
    if (!row || row.clientId !== client.client_id) throw new InvalidGrantError('Invalid or expired refresh token')
    // Rotation: the old refresh token dies; if two refreshes race, only one wins.
    if (!(await revokeOAuthToken(this.o.db, row.tokenHash))) throw new InvalidGrantError('Refresh token already used')
    const narrowed = scopes?.length ? row.scopes.filter(s => scopes.includes(s)) : row.scopes
    // Re-apply the current allowlist so scope changes take effect at the next refresh.
    const current = grantedScopes(this.o.userScopes, row.subject, narrowed)
    if (!current.length) throw new InvalidGrantError('User is no longer allowed to use this connector')
    return this.issueTokens(client.client_id, row.subject, current)
  }

  async verifyAccessToken(token: string): Promise<AuthInfo> {
    const row = await findOAuthToken(this.o.db, hash(token), 'access')
    if (!row) throw new InvalidTokenError('Invalid or expired access token')
    return {
      token,
      clientId: row.clientId,
      scopes: row.scopes,
      expiresAt: Math.floor(row.expiresAt.getTime() / 1000),
      resource: this.o.resource,
      extra: { subject: row.subject },
    }
  }

  async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
    await revokeOAuthToken(this.o.db, hash(request.token))
  }

  // ── internals ────────────────────────────────────────────────────────────

  private async issueTokens(clientId: string, subject: string, scopes: string[]): Promise<OAuthTokens> {
    const access = randomToken()
    const refresh = randomToken()
    const now = Date.now()
    const resource = this.o.resource.href
    await saveOAuthTokens(this.o.db, [
      { tokenHash: hash(access), kind: 'access', clientId, subject, scopes, resource, expiresAt: new Date(now + this.o.accessTtlSeconds * 1000) },
      { tokenHash: hash(refresh), kind: 'refresh', clientId, subject, scopes, resource, expiresAt: new Date(now + this.o.refreshTtlSeconds * 1000) },
    ])
    return { access_token: access, token_type: 'Bearer', expires_in: this.o.accessTtlSeconds, refresh_token: refresh, scope: scopes.join(' ') }
  }

  private sendLoginPage(res: Response, request: string, clientName: string | null, error?: string, username?: string, status = 200) {
    res
      .status(status)
      .set({
        'Content-Type': 'text/html; charset=utf-8',
        'Cache-Control': 'no-store',
        'X-Frame-Options': 'DENY',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
      })
      .send(renderLoginPage({ request, clientName, storeName: this.o.storeName, require2fa: this.o.require2fa, error, username }))
  }

  /** The pending request travels through the form as an HMAC-signed blob — no server-side session. */
  private sign(p: PendingAuthorization): string {
    const payload = Buffer.from(JSON.stringify(p)).toString('base64url')
    const mac = crypto.createHmac('sha256', this.o.secret).update(payload).digest('base64url')
    return `${payload}.${mac}`
  }

  private verify(blob: string | undefined): PendingAuthorization | null {
    if (!blob) return null
    const [payload, mac] = blob.split('.')
    if (!payload || !mac) return null
    const expected = crypto.createHmac('sha256', this.o.secret).update(payload).digest('base64url')
    if (mac.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) return null
    const p = JSON.parse(Buffer.from(payload, 'base64url').toString()) as PendingAuthorization
    return p.exp > Date.now() ? p : null
  }
}
