'use client';

import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'sonner';

import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';

type SettingType =
  | 'string'
  | 'password'
  | 'number'
  | 'boolean'
  | 'select'
  | 'radio'
  | 'url'
  | 'email'
  | 'textarea'
  | 'user-select';
type SettingSource = 'port' | 'global' | 'env' | 'default';

interface RegistryClientEntry {
  key: string;
  section: string;
  label: string;
  description: string;
  type: SettingType;
  options?: Array<{ value: string; label: string }>;
  encrypted: boolean;
  sensitive: boolean;
  scope: 'port' | 'global';
  envFallback?: string;
  placeholder?: string;
  defaultValue?: string | number | boolean | null;
}

interface ResolvedValue {
  key: string;
  source: SettingSource;
  isSet: boolean;
  value?: unknown;
}

interface ResolvedResponse {
  data: { entries: RegistryClientEntry[]; values: Record<string, ResolvedValue> };
}

interface Props {
  /** Section names from the registry to render (e.g. ['documenso.api', 'documenso.signers']). */
  sections: string[];
  /** Card-level title; omit to render fields without a card wrapper. */
  title?: string;
  /** Card-level description. */
  description?: string;
  /** Optional slot below the form (e.g. test-connection button). */
  extra?: ReactNode;
}

/**
 * Generates an editable settings form from the central registry. Renders the
 * "Using env fallback" badge on each field whose resolved source is `env`
 * (or `default`), plus a "Copy from env" button when an env value exists to
 * one-click migrate the value into the admin DB.
 *
 * Encrypted / sensitive fields show ••• placeholder text and never receive
 * the actual cleartext from the server. Saving an empty value on these
 * fields is a no-op (use the explicit DELETE button to revert).
 */
export function RegistryDrivenForm({ sections, title, description, extra }: Props) {
  const queryKey = ['settings', 'resolved', ...sections];
  const queryClient = useQueryClient();
  const { data, isLoading } = useQuery<ResolvedResponse>({
    queryKey,
    queryFn: () =>
      apiFetch<ResolvedResponse>(
        `/api/v1/admin/settings/resolved?sections=${sections.map(encodeURIComponent).join(',')}`,
      ),
  });

  // Lifted draft state - every field's current input value is held here so
  // a card-level "Save N changes" button can write them all in one batch.
  // Sensitive fields seed as empty (we never seed cleartext from the server);
  // non-sensitive fields seed from the resolved value.
  const [drafts, setDrafts] = useState<Record<string, unknown>>({});
  // A field is "dirty" only after the operator types into it. Server-driven
  // events (eye-toggle reveal, copy-from-env autofill) explicitly clear the
  // dirty flag for that key so they don't trigger a phantom save.
  const [dirtyKeys, setDirtyKeys] = useState<Set<string>>(() => new Set());

  // Re-seed drafts whenever the resolved-values query refreshes (after a
  // successful save, revert, or copy-from-env) so values reflect server
  // state. Preserves any in-progress edits the user is making.
  useEffect(() => {
    if (!data) return;
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setDrafts((prev) => {
      const next = { ...prev };
      for (const entry of data.data.entries) {
        if (dirtyKeys.has(entry.key)) continue; // don't trample in-progress edits
        if (entry.encrypted || entry.sensitive) {
          next[entry.key] = '';
        } else {
          next[entry.key] = data.data.values[entry.key]?.value ?? '';
        }
      }
      return next;
    });
  }, [data, dirtyKeys]);

  const setDraft = useCallback((key: string, value: unknown, opts?: { dirty?: boolean }) => {
    setDrafts((prev) => ({ ...prev, [key]: value }));
    if (opts?.dirty !== undefined) {
      setDirtyKeys((prev) => {
        const next = new Set(prev);
        if (opts.dirty) next.add(key);
        else next.delete(key);
        return next;
      });
    }
  }, []);

  // Card-level bulk save. Fires one PUT per dirty field in parallel so the
  // common case ("admin tweaks five fields, hits Save") is one round-trip
  // worth of latency rather than five. Partial failures are surfaced
  // per-field via toast; the resolved-values query gets invalidated once
  // even on partial success so the UI reflects what landed.
  const saveAll = useMutation({
    mutationFn: async () => {
      if (!data)
        return { succeeded: [] as string[], failed: [] as Array<{ key: string; error: unknown }> };
      const dirty = Array.from(dirtyKeys);
      const settled = await Promise.allSettled(
        dirty.map(async (key) => {
          await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(key)}`, {
            method: 'PUT',
            body: { value: drafts[key] },
          });
          return key;
        }),
      );
      const succeeded: string[] = [];
      const failed: Array<{ key: string; error: unknown }> = [];
      settled.forEach((r, i) => {
        const key = dirty[i]!;
        if (r.status === 'fulfilled') succeeded.push(key);
        else failed.push({ key, error: r.reason });
      });
      return { succeeded, failed };
    },
    onSuccess: ({ succeeded, failed }) => {
      // Clear dirty flags for the keys that landed; leave failed ones dirty
      // so the operator can fix + retry.
      if (succeeded.length > 0) {
        setDirtyKeys((prev) => {
          const next = new Set(prev);
          for (const k of succeeded) next.delete(k);
          return next;
        });
        toast.success(
          succeeded.length === 1 ? `Saved 1 setting` : `Saved ${succeeded.length} settings`,
        );
      }
      for (const f of failed) {
        const label = data?.data.entries.find((e) => e.key === f.key)?.label ?? f.key;
        toastError(f.error, `Failed to save ${label}`);
      }
      void queryClient.invalidateQueries({ queryKey });
    },
    onError: (err) => toastError(err, 'Failed to save settings'),
  });

  const dirtyCount = dirtyKeys.size;
  const content = (
    <div className="space-y-6">
      {isLoading || !data ? (
        <div className="flex items-center justify-center py-12 text-muted-foreground">
          <Loader2 className="size-5 animate-spin" />
        </div>
      ) : (
        <>
          {groupBySection(data.data.entries).map(([section, entries]) => (
            <SectionGroup
              key={section}
              entries={entries}
              values={data.data.values}
              drafts={drafts}
              setDraft={setDraft}
              onResolvedRefresh={() => queryClient.invalidateQueries({ queryKey })}
            />
          ))}
          <div className="flex items-center justify-between gap-3 border-t pt-4">
            <div className="text-xs text-muted-foreground">
              {dirtyCount === 0
                ? 'No unsaved changes.'
                : dirtyCount === 1
                  ? '1 unsaved change.'
                  : `${dirtyCount} unsaved changes.`}
            </div>
            <Button
              onClick={() => saveAll.mutate()}
              disabled={saveAll.isPending || dirtyCount === 0}
            >
              {saveAll.isPending ? (
                <>
                  <Loader2 className="mr-1 size-3 animate-spin" /> Saving…
                </>
              ) : dirtyCount > 0 ? (
                `Save ${dirtyCount} change${dirtyCount === 1 ? '' : 's'}`
              ) : (
                'Save'
              )}
            </Button>
          </div>
        </>
      )}
      {extra ? <div className="pt-2">{extra}</div> : null}
    </div>
  );

  if (!title) return content;

  return (
    <Card>
      <CardHeader>
        <CardTitle>{title}</CardTitle>
        {description ? <CardDescription>{description}</CardDescription> : null}
      </CardHeader>
      <CardContent>{content}</CardContent>
    </Card>
  );
}

function groupBySection(entries: RegistryClientEntry[]): Array<[string, RegistryClientEntry[]]> {
  const map = new Map<string, RegistryClientEntry[]>();
  for (const e of entries) {
    const existing = map.get(e.section);
    if (existing) existing.push(e);
    else map.set(e.section, [e]);
  }
  return Array.from(map.entries());
}

function SectionGroup({
  entries,
  values,
  drafts,
  setDraft,
  onResolvedRefresh,
}: {
  entries: RegistryClientEntry[];
  values: Record<string, ResolvedValue>;
  drafts: Record<string, unknown>;
  setDraft: (key: string, value: unknown, opts?: { dirty?: boolean }) => void;
  onResolvedRefresh: () => void;
}) {
  return (
    <div className="space-y-4">
      {entries.map((entry) => (
        <SettingField
          key={entry.key}
          entry={entry}
          resolved={values[entry.key]}
          draft={drafts[entry.key]}
          setDraft={(value, opts) => setDraft(entry.key, value, opts)}
          onResolvedRefresh={onResolvedRefresh}
        />
      ))}
    </div>
  );
}

function SettingField({
  entry,
  resolved,
  draft,
  setDraft,
  onResolvedRefresh,
}: {
  entry: RegistryClientEntry;
  resolved: ResolvedValue | undefined;
  draft: unknown;
  setDraft: (value: unknown, opts?: { dirty?: boolean }) => void;
  onResolvedRefresh: () => void;
}) {
  const [showSecret, setShowSecret] = useState(false);
  // Tracks whether `draft` currently holds a server-revealed value (vs.
  // something the operator just typed). Lets the toggle button hide the
  // revealed value cleanly without wiping a fresh edit.
  const [revealedFromServer, setRevealedFromServer] = useState(false);

  const reveal = useMutation({
    mutationFn: async () => {
      const r = await apiFetch<{ data: { revealed: boolean; value: string | null } }>(
        `/api/v1/admin/settings/${encodeURIComponent(entry.key)}/reveal`,
        { method: 'POST' },
      );
      return r.data;
    },
    onSuccess: (r) => {
      if (r.revealed && r.value != null) {
        // Server reveal - populate draft but do NOT mark dirty (the value
        // already matches what's stored).
        setDraft(r.value, { dirty: false });
        setRevealedFromServer(true);
        setShowSecret(true);
      } else {
        toast.info(`${entry.label} isn't set - nothing to reveal.`);
      }
    },
    onError: (err) => toastError(err, `Failed to reveal ${entry.label}`),
  });

  const revert = useMutation({
    mutationFn: async () => {
      await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(entry.key)}`, {
        method: 'DELETE',
      });
    },
    onSuccess: () => {
      toast.success(`${entry.label} reverted to default`);
      setDraft('', { dirty: false });
      onResolvedRefresh();
    },
    onError: (err) => toastError(err, `Failed to revert ${entry.label}`),
  });

  const copyFromEnv = useMutation({
    mutationFn: async () => {
      const r = await apiFetch<{ data: { copied: boolean; envValue?: string } }>(
        `/api/v1/admin/settings/${encodeURIComponent(entry.key)}/copy-from-env`,
        { method: 'POST' },
      );
      return r.data;
    },
    onSuccess: (r) => {
      if (r.copied) {
        toast.success(`${entry.label} copied from env`);
        if (r.envValue && !entry.sensitive) setDraft(r.envValue, { dirty: false });
      } else {
        toast.info(`No env value to copy for ${entry.label}`);
      }
      onResolvedRefresh();
    },
    onError: (err) => toastError(err, `Failed to copy ${entry.label} from env`),
  });

  const source = resolved?.source ?? 'default';
  const showFallbackBadge = source === 'env' || source === 'default';
  const canCopyFromEnv = !!entry.envFallback && source === 'env';

  return (
    <div className="space-y-1.5 border-l-2 border-l-muted pl-4">
      <div className="flex items-center justify-between gap-2">
        <Label htmlFor={entry.key} className="text-sm font-medium">
          {entry.label}
        </Label>
        <div className="flex items-center gap-1.5">
          {source === 'port' && (
            <Badge variant="secondary" className="text-xs">
              <CheckCircle2 className="mr-1 size-3" />
              Port override
            </Badge>
          )}
          {source === 'global' && (
            <Badge variant="secondary" className="text-xs">
              Global
            </Badge>
          )}
          {showFallbackBadge && resolved?.isSet && (
            <Badge variant="outline" className="text-xs">
              Using env fallback
            </Badge>
          )}
          {showFallbackBadge && !resolved?.isSet && (
            <Badge variant="outline" className="text-xs text-muted-foreground">
              Not set
            </Badge>
          )}
        </div>
      </div>
      {entry.description && (
        <p id={`${entry.key}-description`} className="text-xs text-muted-foreground">
          {entry.description}
        </p>
      )}

      <FieldInput
        entry={entry}
        value={draft}
        onChange={(v) => {
          // User typing → mark dirty so the card-level Save button picks it up.
          setDraft(v, { dirty: true });
          // A fresh keystroke supersedes any prior server-reveal.
          if (revealedFromServer) setRevealedFromServer(false);
        }}
        showSecret={showSecret}
        sensitive={entry.sensitive}
        placeholder={entry.placeholder}
      />

      <div className="flex flex-wrap items-center gap-2 pt-1">
        {canCopyFromEnv && (
          <Button
            variant="outline"
            size="sm"
            onClick={() => copyFromEnv.mutate()}
            disabled={copyFromEnv.isPending}
          >
            <Download className="mr-1 size-3" />
            Copy from env
          </Button>
        )}
        {(source === 'port' || source === 'global') && (
          <Button
            variant="ghost"
            size="sm"
            onClick={() => revert.mutate()}
            disabled={revert.isPending}
          >
            Revert to fallback
          </Button>
        )}
        {entry.sensitive && entry.type === 'password' && (
          <Button
            type="button"
            variant="ghost"
            size="sm"
            disabled={
              reveal.isPending ||
              // Disable when the value is resolved from env/default and the
              // rep hasn't typed anything yet - there's no in-app cleartext
              // path for those, and silently no-op'ing was indistinguishable
              // from a broken toggle.
              (!showSecret &&
                resolved?.isSet === true &&
                (resolved?.source === 'env' || resolved?.source === 'default') &&
                !(typeof draft === 'string' && draft.length > 0))
            }
            title={
              !showSecret &&
              resolved?.isSet === true &&
              (resolved?.source === 'env' || resolved?.source === 'default') &&
              !(typeof draft === 'string' && draft.length > 0)
                ? 'Value comes from the environment. Configure in admin to enable reveal.'
                : showSecret
                  ? 'Hide value'
                  : 'Reveal value'
            }
            onClick={() => {
              if (showSecret) {
                // Hide. If this draft came from the server reveal, drop it so
                // we don't keep cleartext in component state past the toggle.
                if (revealedFromServer) {
                  setDraft('', { dirty: false });
                  setRevealedFromServer(false);
                }
                setShowSecret(false);
                return;
              }
              // Show. If the operator hasn't typed anything yet and the
              // setting is saved on the server, ask the API for cleartext.
              const hasLocalDraft = typeof draft === 'string' && draft.length > 0;
              if (
                !hasLocalDraft &&
                resolved?.isSet &&
                (resolved.source === 'port' || resolved.source === 'global')
              ) {
                reveal.mutate();
              } else {
                setShowSecret(true);
              }
            }}
          >
            {reveal.isPending ? (
              <Loader2 className="size-3 animate-spin" />
            ) : showSecret ? (
              <EyeOff className="size-3" />
            ) : (
              <Eye className="size-3" />
            )}
          </Button>
        )}
      </div>
    </div>
  );
}

function FieldInput({
  entry,
  value,
  onChange,
  showSecret,
  sensitive,
  placeholder,
}: {
  entry: RegistryClientEntry;
  value: unknown;
  onChange: (v: unknown) => void;
  showSecret: boolean;
  sensitive: boolean;
  placeholder?: string;
}) {
  if (entry.type === 'boolean') {
    return (
      <Switch id={entry.key} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
    );
  }
  if (entry.type === 'user-select') {
    return (
      <UserSelectInput
        id={entry.key}
        value={typeof value === 'string' ? value : ''}
        onChange={onChange}
        placeholder={placeholder ?? 'No CRM user linked'}
      />
    );
  }
  if (entry.type === 'radio' && entry.options) {
    // Inline radio group — clearer than a dropdown for 2–3 mutually-exclusive
    // options where each choice has materially different consequences (e.g.
    // auto-vs-manual signing dispatch). Falls through to plain Select when
    // the option count grows.
    const current = value == null ? null : String(value);
    return (
      <div role="radiogroup" aria-labelledby={`${entry.key}-label`} className="space-y-2">
        {entry.options.map((o) => {
          const checked = current === o.value;
          return (
            <label
              key={o.value}
              className="flex cursor-pointer items-start gap-2 rounded-md border border-input bg-background p-2 text-sm hover:bg-accent/40 has-[:checked]:border-primary has-[:checked]:bg-accent/60"
            >
              <input
                type="radio"
                name={entry.key}
                value={o.value}
                checked={checked}
                onChange={() => onChange(o.value)}
                className="mt-1 h-4 w-4 cursor-pointer accent-primary"
              />
              <span className="flex-1">{o.label}</span>
            </label>
          );
        })}
      </div>
    );
  }
  if (entry.type === 'select' && entry.options) {
    // Radix Select rejects an empty-string `value` because that's its internal
    // sentinel for "cleared". Pass `undefined` instead so the placeholder
    // renders cleanly when the resolved value is null/blank.
    const selectValue = value == null || value === '' ? undefined : String(value);
    return (
      <Select value={selectValue} onValueChange={(v) => onChange(v)}>
        <SelectTrigger id={entry.key}>
          <SelectValue placeholder={placeholder ?? 'Choose…'} />
        </SelectTrigger>
        <SelectContent>
          {entry.options.map((o) => (
            <SelectItem key={o.value} value={o.value}>
              {o.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    );
  }
  if (entry.type === 'textarea') {
    return (
      <Textarea
        id={entry.key}
        value={String(value ?? '')}
        placeholder={placeholder}
        onChange={(e) => onChange(e.target.value)}
        rows={4}
      />
    );
  }
  return (
    <Input
      id={entry.key}
      type={
        entry.type === 'password' && !showSecret
          ? 'password'
          : entry.type === 'number'
            ? 'number'
            : entry.type === 'email'
              ? 'email'
              : entry.type === 'url'
                ? 'url'
                : 'text'
      }
      value={sensitive && !showSecret && value === '' ? '' : String(value ?? '')}
      placeholder={sensitive ? '••••••••' : placeholder}
      onChange={(e) =>
        onChange(entry.type === 'number' ? Number(e.target.value || 0) : e.target.value)
      }
    />
  );
}

interface PickerUser {
  id: string;
  email: string;
  name: string;
}

/**
 * Renders a Radix Select of every user in the current port. Stores the
 * user's UUID. A "no link" sentinel value lets the operator clear the
 * binding (Radix can't store empty string as a value, so we map empty ↔
 * `__none__` over the wire).
 */
function UserSelectInput({
  id,
  value,
  onChange,
  placeholder,
}: {
  id: string;
  value: string;
  onChange: (v: string) => void;
  placeholder: string;
}) {
  const { data, isLoading } = useQuery<{ data: PickerUser[] }>({
    queryKey: ['admin', 'users', 'picker'],
    queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
    staleTime: 60_000,
  });
  const NONE = '__none__';
  const selectValue = value ? value : NONE;
  return (
    <Select value={selectValue} onValueChange={(v) => onChange(v === NONE ? '' : v)}>
      <SelectTrigger id={id}>
        <SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value={NONE}> - No CRM user linked - </SelectItem>
        {(data?.data ?? []).map((u) => (
          <SelectItem key={u.id} value={u.id}>
            {u.name || u.email} {u.name ? `· ${u.email}` : ''}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}
