import { useCallback, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient, getHeaders } from 'hooks/useClient';

// Reading and driving the plugin system. One query, four verbs.
//
// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
// asking questions; this installs plugins from the tree and asks nothing.

export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };

/** One host binary the plugin shells out to, probed against this machine's PATH on every read. */
export type DependencyItem = {
  binary: string;
  reason: string;
  path: string | null;
  present: boolean;
  /** What would be installed here, or null when this machine's package manager has no entry for it. */
  packageName: string | null;
};

/**
 * Host binaries, answered for THIS machine.
 *
 * Shown before the Install button rather than only in the log afterwards, because the owner is deciding
 * whether to let the server run a package manager as root — and that decision needs the answer first.
 * Installing the packages by hand and watching this flip to satisfied is the escape hatch on a machine
 * with no passwordless sudo.
 */
export type PluginDependencies = {
  /** apt | pacman | dnf | brew, or null when none was found. */
  manager: string | null;
  items: DependencyItem[];
  satisfied: boolean;
  canInstall: boolean;
  /** Why the platform cannot install them. Written to be read by a person. */
  blockedReason: string | null;
  packagesToInstall: string[];
  /** The command to run by hand. Display only — the server never executes this string. */
  manualHint: string | null;
};

export type PluginItem = {
  appName: string;
  /** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
  prefix: string;
  label: string;
  summary: string;
  icon: string;
  color: string;
  publisher: string;
  version: string;
  platform: string;
  permissions: PluginPermission[];
  /** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */
  has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
  installed: boolean;
  enabled: boolean;
  installedVersion: string | null;
  /** The code on disk moved after it was installed — normal while developing, and worth seeing. */
  outdated: boolean;
  /**
   * PM2's word for the sidecar, or null when the plugin has none. `online` while enabled is healthy;
   * anything else while enabled is the state worth rendering differently — the difference between a
   * plugin that is off and one that is broken.
   */
  processStatus: string | null;
  /** Host binaries and whether this machine has them. Always present; `items` is empty for most plugins. */
  dependencies: PluginDependencies;
};

/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */
export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string };

export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable';

export type BrokenPlugin = { appName: string; error: string };

const PLUGINS_KEY = ['plugins'];

export function usePlugins() {
  const client = useClient();
  const queryClient = useQueryClient();

  const { data, isLoading, error } = useQuery({
    queryKey: PLUGINS_KEY,
    queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
  });

  // Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
  // and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
  const invalidate = useCallback(() => {
    queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
    queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
  }, [queryClient]);

  const [steps, setSteps] = useState<string[]>([]);
  const [result, setResult] = useState<PluginActionResult | null>(null);
  const [running, setRunning] = useState<PluginVerb | null>(null);
  const abort = useRef<AbortController | null>(null);

  /**
   * Run a verb and stream its steps.
   *
   * Not `useMutation`, because react-query models one request with one answer and this is a request with
   * a running commentary. Hand-rolled for the same reason `useCompanionLogStream` is: `EventSource`
   * cannot send an `Authorization` header, and these routes are owner-only.
   *
   * The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has
   * to understand what our own endpoint emits.
   */
  const run = useCallback(
    async (verb: PluginVerb, appName: string) => {
      abort.current?.abort();
      const controller = new AbortController();
      abort.current = controller;

      setSteps([]);
      setResult(null);
      setRunning(verb);

      try {
        const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, {
          method: 'POST',
          headers: getHeaders(),
          signal: controller.signal,
        });
        if (!res.body) throw new Error(`${verb} failed: no response body`);

        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';

        for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });

          // The last element is whatever has arrived since the last blank line — an incomplete frame, so
          // it stays in the buffer rather than being parsed as a short one.
          const frames = buffer.split('\n\n');
          buffer = frames.pop() ?? '';

          for (const frame of frames) {
            let event = 'message';
            let data = '';
            for (const line of frame.split('\n')) {
              if (line.startsWith('event:')) event = line.slice(6).trim();
              else if (line.startsWith('data:')) data += line.slice(5).trim();
            }
            if (!data) continue;
            const parsed = JSON.parse(data) as { step?: string } & PluginActionResult;
            if (event === 'step' && parsed.step) setSteps((prev) => [...prev, parsed.step!]);
            else if (event === 'done') setResult(parsed);
          }
        }
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          setResult({ ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) });
        }
      } finally {
        setRunning(null);
        invalidate();
      }
    },
    [invalidate],
  );

  return {
    plugins: data?.plugins ?? [],
    broken: data?.broken ?? [],
    isLoading,
    error,
    /** Steps as they arrive. Cleared when the next verb starts. */
    steps,
    /** The final answer, once the stream closes. Null while running. */
    result,
    /** Which verb is in flight, or null. */
    running,
    run,
  };
}
