import { Link, router, usePage } from '@inertiajs/react'
import { Ban, ChevronDown, KeyRound, BanIcon } from 'lucide-react'
import type { FormEvent } from 'react'
import { useMemo, useRef, useState } from 'react'

import { addressWithout } from '@/components/address'
import { CodeChip } from '@/components/CodeChip'
import { ConfirmDestructive } from '@/components/ConfirmDestructive'
import { CopyButton } from '@/components/CopyButton'
import { DataTable, createDataTableColumns, tableUrl } from '@/components/DataTable'
import type { DataTableColumn, TablePage } from '@/components/DataTable'
import { DataTimestamp, TimezoneFootnote } from '@/components/DataTimestamp'
import { fieldErrorProps } from '@/components/FieldError'
import { FormDialog } from '@/components/FormDialog'
import { SecretRevealDialog } from '@/components/SecretRevealDialog'
import { Section } from '@/components/Section'
import { ServerErrorNotice } from '@/components/ServerErrorNotice'
import { StatusBadge } from '@/components/StatusBadge'
import { AppLayout } from '@/layouts/AppLayout'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { t } from '@/lib/i18n'
import { route } from '@/lib/routes'

interface ApiKeyRow {
  id: string
  name: string
  prefix: string
  created_at: string
  last_used_at: string | null
  revoked_at: string | null
}

/** Lo mismo, más el valor completo. Sólo existe en memoria y sólo una vez. */
interface CreatedKey extends ApiKeyRow {
  key: string
}

interface ServerError {
  code: string
  message: string
  details?: Record<string, unknown> | null
}

interface Props {
  keys: TablePage<ApiKeyRow>
  /**
   * Aparte y paginadas: son el dato que se busca después de un incidente
   * (FR-061) y no se borran nunca, así que la lista sólo crece (RT-09).
   */
  revoked: TablePage<ApiKeyRow>
  /** Nombre de la que se acaba de revocar, para confirmar el cambio (RT-11). */
  revoked_name: string | null
  docs_url: string
  api_base: string
}

/**
 * Las tres fases del alta, que son **una sola** máquina de estados.
 *
 * De acá sale la regla que hace que el momento del secreto no tenga grietas: el
 * diálogo de creación y el de revelación nunca están montados a la vez —el
 * contrato prohíbe anidar diálogos— y **nunca están los dos desmontados con la
 * respuesta ya en la mano**. Un solo cambio de fase los intercambia, así que no
 * hay ni un cuadro en el que la pantalla esté clickeable con la clave recién
 * emitida esperando.
 *
 * `closing` existe por la misma razón, del otro lado: al cerrarse la revelación
 * hay que sacar `?new` de la dirección, y esa navegación tarda. Sin esta fase,
 * el alta volvería a abrirse sola durante el viaje de ida y vuelta.
 */
type CreationPhase = 'idle' | 'creating' | 'revealing' | 'closing'

/** Qué dice la dirección cuando el alta está abierta. */
const CREATE_PARAM = 'new'

const columnHelper = createDataTableColumns<ApiKeyRow>()

/**
 * Las columnas de las claves vigentes.
 *
 * Los identificadores coinciden con las claves que el servidor declara
 * ordenables en `API_KEYS`: si no coincidieran, el encabezado ofrecería un orden
 * que el servidor descarta en silencio y el clic parecería roto.
 *
 * La acción de revocar queda como **botón** y no como menú de fila: es la única
 * que existe sobre una clave vigente, y el árbol de decisión reserva el
 * desplegable para dos o más. Un menú con un ítem esconde la única acción de la
 * fila detrás de un clic de más.
 */
function apiKeyColumns(onRevoke: (key: ApiKeyRow) => void): DataTableColumn<ApiKeyRow>[] {
  return [
    columnHelper.accessor('name', {
      id: 'name',
      header: t('apiKeys.column.name'),
      cell: (info) => (
        <span translate="no" className="font-medium">
          {info.getValue()}
        </span>
      ),
    }),
    columnHelper.accessor('prefix', {
      id: 'prefix',
      header: t('apiKeys.column.prefix'),
      cell: (info) => <CodeChip value={`${info.getValue()}…`} className="text-xs" />,
    }),
    columnHelper.accessor('created_at', {
      id: 'created',
      header: t('apiKeys.column.created'),
      cell: (info) => <DataTimestamp value={info.getValue()} />,
    }),
    columnHelper.accessor('last_used_at', {
      id: 'used',
      header: t('apiKeys.column.lastUse'),
      cell: (info) => <DataTimestamp value={info.getValue()} emptyLabel={t('apiKeys.neverUsed')} />,
    }),
    columnHelper.display({
      id: 'actions',
      header: t('sessions.column.actions'),
      cell: (info) => (
        <div>
          <Button
            variant="destructive"
            size="sm"
            className="gap-2"
            onClick={() => onRevoke(info.row.original)}
            // Nombre único por fila: cuatro botones «Revocar» obligan a contar
            // renglones para saber cuál se está por apretar, en una acción que
            // no se puede deshacer.
            aria-label={`Revocar la clave ${info.row.original.name}`}
          >
            <BanIcon className="size-4" />
            <span>{t('apiKeys.revoke')}</span>
          </Button>
        </div>
      ),
    }),
  ]
}

/**
 * Las columnas de las revocadas, **sin ninguna de acciones**.
 *
 * No hay acción posible sobre una clave revocada: no se reactiva y no se borra.
 * Un «Revocar» apagado en su lugar sería ofrecer algo que no existe (RT-07).
 *
 * El estado va en un badge debajo del nombre y no en filas grises: el color solo
 * no comunica nada en escala de grises ni en un recorte de pantalla pegado en un
 * informe de incidente (RT-04). Y queda en el mismo lugar donde la pantalla de
 * sesiones pone «Esta sesión»: una anatomía de primera celda para todo el
 * producto.
 */
function revokedColumns(): DataTableColumn<ApiKeyRow>[] {
  return [
  columnHelper.accessor('name', {
    id: 'name',
    header: t('apiKeys.column.name'),
    cell: (info) => (
      <div className="flex flex-col items-start gap-1">
        <span translate="no" className="font-medium">
          {info.getValue()}
        </span>
        <StatusBadge tone="critical" icon={Ban}>
          {t('apiKeys.revoked')}
        </StatusBadge>
      </div>
    ),
  }),
  columnHelper.accessor('prefix', {
    id: 'prefix',
    header: t('apiKeys.column.prefix'),
    cell: (info) => <CodeChip value={`${info.getValue()}…`} className="text-xs" />,
  }),
  columnHelper.accessor('revoked_at', {
    id: 'revoked',
    header: t('apiKeys.column.revoked'),
    cell: (info) => <DataTimestamp value={info.getValue()} />,
  }),
  columnHelper.accessor('last_used_at', {
    id: 'used',
    header: t('apiKeys.column.lastUse'),
    cell: (info) => <DataTimestamp value={info.getValue()} emptyLabel={t('apiKeys.neverUsed')} />,
  }),
  ]
}

export default function ApiKeys({ keys, revoked, revoked_name, docs_url, api_base }: Props) {
  const { url } = usePage()

  /*
    El valor completo vive acá y en ningún otro lado (RT-06). No viene en las
    props, así que recargar o volver con el botón «Atrás» no lo muestra de
    nuevo: se pierde al navegar, que es exactamente lo que el contrato promete.
  */
  const [revealed, setRevealed] = useState<CreatedKey | null>(null)
  const [phase, setPhase] = useState<CreationPhase>('idle')
  const [toRevoke, setToRevoke] = useState<ApiKeyRow | null>(null)
  const [revoking, setRevoking] = useState(false)

  const columns = useMemo(() => apiKeyColumns(setToRevoke), [])

  // El alta abre desde la dirección, así que su disparador es un enlace de
  // verdad: se abre en otra pestaña, se copia y se guarda como cualquier otro.
  const createHref = tableUrl(keys, { [CREATE_PARAM]: 1 })

  const revoke = () => {
    if (!toRevoke) return
    router.post(
      route('api_key.revoke', { key_id: toRevoke.id }),
      {},
      {
        preserveScroll: true,
        onStart: () => setRevoking(true),
        onFinish: () => setRevoking(false),
      }
    )
  }

  /*
    Cerrar la revelación es lo único que saca `?new` de la dirección, y va con
    `replace` para no dejar una entrada de historial que devuelva al formulario
    con la lista vieja. La lista se vuelve a pedir en el mismo viaje: el orden y
    la página los decide el servidor, y una fila insertada a mano donde el
    servidor no la habría puesto desaparece en la primera navegación.
  */
  const dismissReveal = () => {
    setPhase('closing')
    router.get(
      addressWithout(url, CREATE_PARAM),
      {},
      {
        replace: true,
        preserveScroll: true,
        preserveState: true,
        // El valor se suelta recién acá y no al apretar el botón: con `open` en
        // falso el diálogo todavía tiene que correr su salida, y desmontarlo de
        // golpe lo haría desaparecer sin transición justo en la pantalla donde
        // el movimiento dice que algo terminó.
        onFinish: () => {
          setPhase('idle')
          setRevealed(null)
        },
      }
    )
  }

  return (
    <AppLayout
      title={t('apiKeys.title')}
      description={t('apiKeys.description')}
      actions={
        <Button asChild>
          <Link href={createHref} preserveScroll preserveState>
            {t('apiKeys.create')}
          </Link>
        </Button>
      }
    >
      <Section title={{ text: t('apiKeys.active.title') }}>
        {/*
          La revocación redirige, así que el resultado lo anuncia la pantalla de
          destino y dice adónde se fue la fila: sin esa segunda mitad, la clave
          parece haberse borrado (RT-11). Se rinde sólo cuando hay algo que
          decir: el anuncio llega por navegación completa de Inertia, así que la
          región viva no necesita preexistir, y reservarle el alto costaba
          veinte píxeles en cada visita para un mensaje que aparece una vez por
          revocación.
        */}
        {revoked_name ? (
          <p role="status" aria-live="polite" className="text-sm">
            {t('apiKeys.revokedNotice.before')}{' '}
            <span translate="no">{revoked_name}</span>
            {t('apiKeys.revokedNotice.after')}
          </p>
        ) : null}

        <DataTable
          data={keys}
          columns={columns}
          paramPrefix="active_"
          unit={t('apiKeys.active.unit')}
          unitSingular={t('apiKeys.active.unitSingular')}
          caption={t('apiKeys.active.caption')}
          rowId={(row) => row.id}
          empty={{
            icon: KeyRound,
            title: t('apiKeys.empty.title'),
            description: t('apiKeys.empty.description'),
            action: (
              <Button asChild>
                <Link href={createHref} preserveScroll preserveState>
                  {t('apiKeys.empty.action')}
                </Link>
              </Button>
            ),
          }}
        />
      </Section>

      {/*
        La sección entera mide cero píxeles mientras no haya ninguna revocada:
        un encabezado con su explicación arriba de una tabla vacía es doscientos
        píxeles diciendo que no pasó nada.
      */}
      {revoked.count > 0 ? <RevokedKeys keys={revoked} /> : null}

      <UsageGuide
        docsUrl={docs_url}
        apiBase={api_base}
        // Abierto sólo cuando la cuenta todavía no tiene ninguna clave: es
        // material de referencia que se lee una vez, y a quien ya integró su
        // pipeline le costaba doscientos cincuenta píxeles en cada visita.
        openByDefault={keys.count === 0 && revoked.count === 0}
      />

      {/*
        Los dos diálogos del alta se intercambian con la fase y nunca conviven.
      */}
      {phase === 'idle' || phase === 'creating' ? (
        <CreateKeyDialog
          creating={phase === 'creating'}
          onCreating={(running) => setPhase(running ? 'creating' : 'idle')}
          onCreated={(created) => {
            setRevealed(created)
            setPhase('revealing')
          }}
        />
      ) : null}

      {revealed ? (
        <SecretRevealDialog
          open={phase === 'revealing'}
          title={t('apiKeys.reveal.title')}
          description={t('apiKeys.reveal.description')}
          value={revealed.key}
          valueLabel={t('apiKeys.reveal.valueLabel')}
          footnote={
            <>
              {t('apiKeys.reveal.footnote.before')}{' '}
              <span translate="no" className="font-medium">
                {revealed.name}
              </span>
              {t('apiKeys.reveal.footnote.middle')}{' '}
              <code translate="no" className="font-mono">
                {revealed.prefix}…
              </code>
            </>
          }
          onDismiss={dismissReveal}
        />
      ) : null}

      {toRevoke ? (
        <ConfirmDestructive
          open
          onOpenChange={(isOpen) => {
            if (!isOpen) setToRevoke(null)
          }}
          title={t('apiKeys.revokeConfirm.title', { name: toRevoke.name })}
          consequence={t('apiKeys.revokeConfirm.consequence')}
          irreversible
          confirmLabel={t('apiKeys.revokeConfirm.confirm')}
          busyLabel={t('apiKeys.revokeConfirm.busy')}
          busy={revoking}
          onConfirm={revoke}
        />
      ) : null}

      <TimezoneFootnote />
    </AppLayout>
  )
}

/**
 * El alta, con un solo campo.
 *
 * No usa el formulario de Inertia como el resto de las pantallas: la respuesta
 * trae el único ejemplar del secreto, y una prop de Inertia queda guardada en
 * el historial del navegador —el botón «Atrás» la volvería a mostrar—. Por eso
 * el envío va por `fetch` y el valor no sale del estado de esta pantalla
 * (RT-06). El diálogo cambia dónde se dibuja el formulario, no cómo viaja la
 * respuesta.
 *
 * Y por eso mismo el servidor no lo cierra: no redirige, contesta JSON. Lo
 * cierra la fase, que es lo que sabe que la respuesta llegó bien.
 */
function CreateKeyDialog({
  creating,
  onCreating,
  onCreated,
}: {
  creating: boolean
  onCreating: (running: boolean) => void
  onCreated: (apiKey: CreatedKey) => void
}) {
  const [name, setName] = useState('')
  const [fieldError, setFieldError] = useState('')
  const [serverError, setServerError] = useState<ServerError | null>(null)

  const field = useRef<HTMLInputElement>(null)

  const fail = (error: ServerError) => {
    // El campo que el servidor señala manda el mensaje al lado del control; lo
    // demás no tiene dónde ir pegado y sale como aviso de `C-13` (RT-08).
    if (error.details?.field === 'name') {
      setFieldError(error.message)
      field.current?.focus()
      return
    }
    setServerError(error)
  }

  const create = async (event: FormEvent) => {
    event.preventDefault()

    const trimmed = name.trim()
    setFieldError('')
    setServerError(null)

    // Se valida acá con la misma regla que el servidor, que sigue siendo la
    // autoridad y vuelve a comprobar. Sólo se ahorra un viaje para decir algo
    // que ya se sabe.
    if (!trimmed) {
      setFieldError(t('apiKeys.form.nameRequired'))
      field.current?.focus()
      return
    }

    onCreating(true)
    try {
      const response = await fetch(route('api_key.create'), {
        method: 'POST',
        credentials: 'same-origin',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRFToken': csrfToken(),
        },
        body: JSON.stringify({ name: trimmed }),
      })
      const body = await response.json()

      if (!response.ok) {
        fail(body.error ?? { code: 'INTERNAL_ERROR', message: '' })
        onCreating(false)
        return
      }

      setName('')
      // Sin `onCreating(false)` a propósito: quien recibe la clave la pasa
      // directo a la fase de revelación, y volver a `idle` en el medio dejaría
      // un instante con el secreto emitido y ningún diálogo montado.
      onCreated(body as CreatedKey)
    } catch {
      // Sin respuesta del servidor no hay código que traducir: `NETWORK_ERROR`
      // es nuestro y lo explica el catálogo, como cualquier otro.
      setServerError({ code: 'NETWORK_ERROR', message: '' })
      onCreating(false)
    }
  }

  return (
    <FormDialog
      param={CREATE_PARAM}
      title={t('apiKeys.form.title')}
      description={t('apiKeys.form.description')}
      submitLabel={t('apiKeys.form.submit')}
      pendingLabel={t('apiKeys.form.pending')}
      pending={creating}
      onSubmit={create}
    >
      {serverError ? (
        <ServerErrorNotice
          code={serverError.code}
          message={serverError.message}
          details={serverError.details}
          codes={['NETWORK_ERROR']}
          action={
            <Button size="sm" variant="outline" onClick={() => setServerError(null)}>
              {t('apiKeys.form.retry')}
            </Button>
          }
        />
      ) : null}

      <Field data-invalid={fieldError ? true : undefined}>
        <FieldLabel htmlFor="name">{t('apiKeys.column.name')}</FieldLabel>
        <Input
          id="name"
          name="name"
          ref={field}
          type="text"
          autoComplete="off"
          spellCheck={false}
          autoCapitalize="none"
          maxLength={100}
          placeholder={t('apiKeys.form.placeholder')}
          value={name}
          onChange={(event) => {
            setFieldError('')
            setName(event.target.value)
          }}
          aria-describedby="help-name"
          {...fieldErrorProps('help-name error-name', fieldError)}
        />
        <FieldDescription id="help-name">
          {t('apiKeys.form.help')}{' '}
          <code translate="no" className="bg-muted/60 rounded px-1 font-mono text-xs">
            {t('apiKeys.form.helpExample')}
          </code>
          .
        </FieldDescription>
        {fieldError ? <FieldError id="error-name">{fieldError}</FieldError> : null}
      </Field>
    </FormDialog>
  )
}

/**
 * Las claves que ya no sirven, con su fecha y sin ninguna acción.
 *
 * No se ocultan ni se mezclan con las vigentes: después de un incidente lo que
 * se busca es qué clave estuvo viva, hasta cuándo y cuándo se usó por última vez
 * (FR-061). Nada de un filtro «Mostrar revocadas» apagado por omisión: eso es
 * justamente lo que el checklist prohíbe.
 *
 * Es una tabla del sistema como la de arriba, con su prefijo propio en la
 * dirección: las dos listas comparten querystring y sin prefijo pasar a la
 * página 2 de una movería también la otra.
 */
function RevokedKeys({ keys }: { keys: TablePage<ApiKeyRow> }) {
  return (
    <Section
      title={{ text: t('apiKeys.revoked.title') }}
      description={t('apiKeys.revoked.description')}
    >
      <DataTable
        data={keys}
        columns={revokedColumns()}
        paramPrefix="revoked_"
        unit={t('apiKeys.revoked.unit')}
        unitSingular={t('apiKeys.revoked.unitSingular')}
        caption={t('apiKeys.revoked.caption')}
        rowId={(row) => row.id}
        empty={{
          icon: Ban,
          title: t('apiKeys.revoked.empty.title'),
          description: t('apiKeys.revoked.empty.description'),
        }}
      />
    </Section>
  )
}

/**
 * Cómo se usa la clave desde un pipeline, con el ejemplo listo para copiar.
 *
 * Sin esto, la pantalla entrega un secreto y no dice qué hacer con él: la
 * documentación completa está a un clic, pero la línea que resuelve el noventa
 * por ciento de los casos tiene que estar acá.
 *
 * Plegado, salvo cuando la cuenta todavía no tiene ninguna clave. El título
 * queda siempre visible, así que no se esconde nada: lo que cambia es que deja
 * de costar el alto de un ejemplo a quien ya integró su pipeline y viene sólo a
 * mirar la lista.
 */
function UsageGuide({
  docsUrl,
  apiBase,
  openByDefault,
}: {
  docsUrl: string
  apiBase: string
  openByDefault: boolean
}) {
  const example =
    `curl -X POST '${apiBase}/domains/<id-del-dominio>/sync' \\\n` +
    `  -H 'Authorization: Api-Key <tu-clave>'`

  return (
    <Collapsible defaultOpen={openByDefault} asChild>
      <Card>
        <CardHeader>
          <CardTitle asChild>
            <h2>{t('apiKeys.usage.title')}</h2>
          </CardTitle>
          <CardDescription className="text-pretty">
            {t('apiKeys.usage.header')}{' '}
            <code translate="no" className="font-mono">
              Authorization
            </code>
            {t('apiKeys.usage.format')}{' '}
            <code translate="no" className="font-mono">
              Api-Key &lt;{t('apiKeys.usage.yourKey')}&gt;
            </code>
            .
          </CardDescription>
          <CardAction>
            <CollapsibleTrigger asChild>
              <Button
                variant="ghost"
                size="sm"
                className="[&>svg]:transition-transform [&>svg]:duration-150 [&[data-state=open]>svg]:rotate-180 motion-reduce:[&>svg]:transition-none"
              >
                <ChevronDown aria-hidden />
                {t('apiKeys.usage.seeExample')}
              </Button>
            </CollapsibleTrigger>
          </CardAction>
        </CardHeader>

        <CollapsibleContent>
          <CardContent className="space-y-3">
            <div className="flex flex-wrap items-start gap-2">
              <pre className="bg-muted/60 min-w-0 flex-1 overflow-x-auto rounded p-3 text-xs">
                <code translate="no">{example}</code>
              </pre>
              <CopyButton value={example} label={t('apiKeys.usage.copyExample')} />
            </div>
            <p className="text-muted-foreground text-sm text-pretty">
              {t('apiKeys.usage.caveat')}
            </p>
            <Button asChild variant="outline" size="sm">
              <a href={docsUrl}>{t('apiKeys.usage.docs')}</a>
            </Button>
          </CardContent>
        </CollapsibleContent>
      </Card>
    </Collapsible>
  )
}

/**
 * El testigo anti-CSRF que Django dejó en la cookie.
 *
 * El cliente de Inertia lo manda solo, pero este envío no pasa por él: sin la
 * cabecera, Django responde 403 y el formulario parecería roto sin decir por
 * qué. La cookie la emite el middleware de props compartidas en cada respuesta,
 * así que siempre está.
 */
function csrfToken(): string {
  const entry = document.cookie
    .split('; ')
    .find((chunk) => chunk.startsWith('csrftoken='))
  return entry ? decodeURIComponent(entry.slice('csrftoken='.length)) : ''
}
