import type { Currency, CustomerStats, Dashboard, Lookups, Paginated, ReportKind, Summary } from './shared';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';

// Data layer for the /invoices panels. Everything goes through the /api/invoiceshelf auth proxy to the
// officer-invoiceshelf sidecar, which holds the InvoiceShelf URL, the Sanctum token and the company header.
//
// Unlike the Transmission panels this does NOT poll. InvoiceShelf is a bookkeeping app whose records only
// change when someone in this UI changes them — a background refetch would spend requests to re-render
// identical rows. Freshness comes from invalidating after writes instead, plus a refetch when the window
// regains focus in case the change was made in InvoiceShelf's own UI.
//
// Mutations invalidate the whole ['invoiceshelf'] prefix rather than patching caches. The totals are deeply
// entangled — recording a payment moves the invoice's due_amount AND its paid_status AND the customer's
// due_amount AND four dashboard figures — so a targeted patch would be subtly wrong far more often than it
// would be cheap.

const ROOT = 'invoiceshelf';
const BASE = '/invoiceshelf/_officer';

/** Records are stable between writes; this only bounds how long a remount reuses the cache. */
const STALE_MS = 30_000;

const EMPTY: never[] = [];

const qs = (params?: Record<string, string | number | undefined | null>): string => {
  if (!params) return '';
  const search = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null || value === '') continue;
    search.set(key, String(value));
  }
  const out = search.toString();
  return out ? `?${out}` : '';
};

/**
 * Pull a human message out of a thrown client error.
 *
 * useClient throws `{status, message}` where message is the raw response body, so the useful text is
 * usually one JSON level down. Laravel reports validation failures as `{message, errors: {field: [msg]}}`
 * and the field-level message is the specific one — "The due date must be a date after invoice date" beats
 * "The given data was invalid."
 */
function errorMessage(err: unknown, fallback: string): string {
  const raw = typeof err === 'object' && err !== null && 'message' in err ? String((err as Error).message) : '';
  if (!raw) return fallback;
  try {
    const parsed = JSON.parse(raw) as { error?: string; message?: string; errors?: Record<string, string[]> };
    const firstField = parsed.errors && Object.values(parsed.errors)[0]?.[0];
    return firstField || parsed.error || parsed.message || fallback;
  } catch {
    return raw || fallback;
  }
}

// ── company-wide context ─────────────────────────────────────────────────────────────────────────

/**
 * The one call every panel needs: who we are, the company, its currency and the dashboard totals.
 *
 * The currency matters beyond the dashboard — it carries precision, separators and symbol placement, and
 * every money value in the UI is unrenderable without it. Panels take it from here rather than each
 * fetching it, so there is a single cache entry keeping them consistent.
 */
export function useSummary() {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, 'summary'] as const,
    queryFn: () => get<Summary>(`${BASE}/summary`),
    staleTime: STALE_MS,
  });

  return {
    summary: query.data ?? null,
    currency: query.data?.currency ?? null,
    company: query.data?.company ?? null,
    dashboard: query.data?.dashboard ?? null,
    isLoading: query.isLoading,
    error: query.error,
  };
}

/** Convenience for the many components that need only the currency to render money. */
export function useCurrency(): Currency | null {
  return useSummary().currency;
}

/**
 * Every select-box option in one call: customers, items, units, tax types, categories, payment methods,
 * currencies. Held long because these are small, near-static reference lists that half the forms need.
 */
export function useLookups() {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, 'lookups'] as const,
    queryFn: () => get<Lookups>(`${BASE}/lookups`),
    staleTime: 5 * 60_000,
  });

  const data = query.data;
  return {
    customers: data?.customers ?? EMPTY,
    items: data?.items ?? EMPTY,
    units: data?.units ?? EMPTY,
    taxTypes: data?.taxTypes ?? EMPTY,
    categories: data?.categories ?? EMPTY,
    paymentMethods: data?.paymentMethods ?? EMPTY,
    currencies: data?.currencies ?? EMPTY,
    isLoading: query.isLoading,
  };
}

export function useDashboard(): { dashboard: Dashboard | null; isLoading: boolean } {
  const { dashboard, isLoading } = useSummary();
  return { dashboard, isLoading };
}

// ── generic resource access ──────────────────────────────────────────────────────────────────────

export type ListParams = Record<string, string | number | undefined | null>;

/**
 * A page of any allow-listed resource. `params` is passed to upstream verbatim, which is what makes one
 * hook enough for seven list screens — InvoiceShelf's index endpoints share a filter vocabulary
 * (`page`, `limit`, `search`, `customer_id`, `status`, `from_date`, `to_date`, `orderByField`, `orderBy`).
 *
 * `params` is spread into the query key, so changing a filter is a new cache entry rather than a refetch
 * of the old one — paging back and forth is then free.
 */
export function useResourceList<T>(resource: string, params?: ListParams, enabled = true) {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, resource, 'list', params ?? {}] as const,
    queryFn: () => get<Paginated<T>>(`${BASE}/${resource}${qs(params)}`),
    enabled,
    staleTime: STALE_MS,
    // Keeps the previous page on screen while the next one loads, instead of flashing an empty table.
    placeholderData: (prev) => prev,
  });

  return {
    rows: query.data?.data ?? (EMPTY as T[]),
    meta: query.data?.meta ?? null,
    isLoading: query.isLoading,
    isFetching: query.isFetching,
    error: query.error,
  };
}

/**
 * One record by id. Upstream wraps singular reads in a `data` key for most resources but returns some bare,
 * so the response is unwrapped defensively rather than assuming either.
 */
export function useResourceDetail<T>(resource: string, id: number | null) {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, resource, 'detail', id] as const,
    queryFn: async () => {
      const res = await get<{ data?: T } | T>(`${BASE}/${resource}/${id}`);
      return (res && typeof res === 'object' && 'data' in res ? (res as { data: T }).data : (res as T)) ?? null;
    },
    enabled: id != null,
    staleTime: STALE_MS,
  });

  return { record: query.data ?? null, isLoading: query.isLoading, error: query.error };
}

/**
 * Create / update / delete / bulk-delete for one resource.
 *
 * All four invalidate the entire ['invoiceshelf'] prefix on success — see the header note on why targeted
 * cache patching is the wrong trade here.
 */
export function useResourceMutations(resource: string, label = resource) {
  const { post, put, delete: del } = useClient();
  const qc = useQueryClient();

  const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });

  const create = useMutation({
    mutationFn: (payload: unknown) => post<{ data?: unknown }>(`${BASE}/${resource}`, payload),
    onSuccess: () => {
      invalidate();
      toast.success(`${singular(label)} created`);
    },
    onError: (err) => toast.error(errorMessage(err, `Could not create ${singular(label).toLowerCase()}`)),
  });

  const update = useMutation({
    mutationFn: ({ id, payload }: { id: number; payload: unknown }) =>
      put<{ data?: unknown }>(`${BASE}/${resource}/${id}`, payload),
    onSuccess: () => {
      invalidate();
      toast.success(`${singular(label)} saved`);
    },
    onError: (err) => toast.error(errorMessage(err, `Could not save ${singular(label).toLowerCase()}`)),
  });

  const remove = useMutation({
    mutationFn: (id: number) => del<unknown>(`${BASE}/${resource}/${id}`),
    onSuccess: () => {
      invalidate();
      toast.success(`${singular(label)} deleted`);
    },
    onError: (err) => toast.error(errorMessage(err, `Could not delete ${singular(label).toLowerCase()}`)),
  });

  const removeMany = useMutation({
    mutationFn: (ids: number[]) => post<unknown>(`${BASE}/${resource}/delete`, { ids }),
    onSuccess: (_data, ids) => {
      invalidate();
      toast.success(`${ids.length} ${ids.length === 1 ? singular(label).toLowerCase() : label} deleted`);
    },
    onError: (err) => toast.error(errorMessage(err, `Could not delete ${label}`)),
  });

  return { create, update, remove, removeMany };
}

const singular = (label: string): string => {
  const base = label.endsWith('ies') ? `${label.slice(0, -3)}y` : label.endsWith('s') ? label.slice(0, -1) : label;
  return base.charAt(0).toUpperCase() + base.slice(1);
};

/**
 * The per-document verbs: status, clone, send, convert-to-invoice, duplicate.
 *
 * `send` genuinely emails the customer. It is reachable from here because the UI needs it, but nothing in
 * this module calls it on its own — it must always be behind an explicit confirm step, never a side effect
 * of saving or of any other action.
 */
export function useDocumentAction(resource: string) {
  const { post } = useClient();
  const qc = useQueryClient();

  return useMutation({
    mutationFn: ({ id, action, payload }: { id: number; action: string; payload?: unknown }) =>
      post<unknown>(`${BASE}/${resource}/${id}/${action}`, payload ?? {}),
    onSuccess: (_data, vars) => {
      qc.invalidateQueries({ queryKey: [ROOT] });
      toast.success(ACTION_TOAST[vars.action] ?? 'Done');
    },
    onError: (err) => toast.error(errorMessage(err, 'Action failed')),
  });
}

const ACTION_TOAST: Record<string, string> = {
  status: 'Status updated',
  clone: 'Copy created',
  send: 'Sent',
  'convert-to-invoice': 'Converted to invoice',
  duplicate: 'Duplicated',
};

/** The number the next document of this kind would take, for the create form's placeholder. */
export function useNextNumber(key: 'invoice' | 'estimate' | 'payment', enabled = true) {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, 'next-number', key] as const,
    queryFn: () => get<{ nextNumber?: string; nextSequenceNumber?: number }>(`${BASE}/next-number?key=${key}`),
    enabled,
    // Never cached: two create forms opened in sequence must not both claim the same number.
    staleTime: 0,
    gcTime: 0,
  });

  return query.data?.nextNumber ?? null;
}

/**
 * The PDF templates a document can be rendered with. `template_name` is a *required* field on every
 * invoice/estimate write, so the create form needs this before it can save anything — it is not decoration.
 */
export function useTemplates(resource: 'invoices' | 'estimates') {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, resource, 'templates'] as const,
    queryFn: () => get<{ [key: string]: unknown }>(`${BASE}/${resource}/templates`),
    staleTime: 60 * 60_000,
  });

  // Upstream keys the payload by resource — `{invoiceTemplates: […]}` / `{estimateTemplates: […]}` — so it
  // is read positionally rather than by a name that differs per endpoint.
  const list = Object.values(query.data ?? {}).find(Array.isArray) as { name: string; path?: string }[] | undefined;
  return list ?? (EMPTY as { name: string; path?: string }[]);
}

export function useCustomerStats(id: number | null) {
  const { get } = useClient();

  const query = useQuery({
    queryKey: [ROOT, 'customers', 'stats', id] as const,
    queryFn: () => get<CustomerStats>(`${BASE}/customers/${id}/stats`),
    enabled: id != null,
    staleTime: STALE_MS,
  });

  return { stats: query.data ?? null, isLoading: query.isLoading };
}

// ── PDFs ─────────────────────────────────────────────────────────────────────────────────────────

type PdfState = { url: string | null; isLoading: boolean; error: string | null };

/**
 * Fetch a PDF and expose it as an object URL an <iframe> can render.
 *
 * It has to be done this way round: the sidecar sits behind Officer's bearer auth, and a naked
 * `<iframe src="/api/...">` cannot carry an Authorization header — it would fetch unauthenticated and
 * render a 401 body. So the bytes are pulled with the authenticated client and handed to the iframe as a
 * blob: URL instead.
 *
 * The previous URL is revoked whenever the target changes and on unmount; without that, every document a
 * user clicks through leaks its PDF into memory until the tab closes.
 */
export function usePdfUrl(resource: string, id: number | null, enabled = true) {
  const { getBlob } = useClient();
  const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });

  useEffect(() => {
    if (id == null || !enabled) {
      setState({ url: null, isLoading: false, error: null });
      return;
    }

    let objectUrl: string | null = null;
    // Guards against an out-of-order response overwriting a newer one when the user clicks quickly.
    let cancelled = false;

    setState({ url: null, isLoading: true, error: null });

    getBlob(`${BASE}/${resource}/${id}/pdf`)
      .then((blob) => {
        if (cancelled) return;
        objectUrl = URL.createObjectURL(blob);
        setState({ url: objectUrl, isLoading: false, error: null });
      })
      .catch((err: unknown) => {
        if (cancelled) return;
        setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not load PDF') });
      });

    return () => {
      cancelled = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
    // getBlob is recreated on every useClient() call, so depending on it would refetch forever.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [resource, id, enabled]);

  return state;
}

/** Same trick for the report PDFs, which are parameterised by date range rather than by id. */
export function useReportUrl(kind: ReportKind | null, params: ListParams, enabled = true) {
  const { getBlob } = useClient();
  const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
  const key = `${kind ?? ''}${qs(params)}`;

  useEffect(() => {
    if (!kind || !enabled) {
      setState({ url: null, isLoading: false, error: null });
      return;
    }

    let objectUrl: string | null = null;
    let cancelled = false;

    setState({ url: null, isLoading: true, error: null });

    getBlob(`${BASE}/reports/${kind}${qs(params)}`)
      .then((blob) => {
        if (cancelled) return;
        objectUrl = URL.createObjectURL(blob);
        setState({ url: objectUrl, isLoading: false, error: null });
      })
      .catch((err: unknown) => {
        if (cancelled) return;
        setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not build report') });
      });

    return () => {
      cancelled = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
    // `key` collapses kind+params into one comparable string; see the note in usePdfUrl about getBlob.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [key, enabled]);

  return state;
}

/** Save a fetched PDF to disk under a readable name. */
export function downloadPdf(url: string, filename: string) {
  const a = document.createElement('a');
  a.href = url;
  a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
  document.body.appendChild(a);
  a.click();
  a.remove();
}

export { errorMessage };

// ── accounts ─────────────────────────────────────────────────────────────────────────────────────
//
// The InvoiceShelf URL, its Sanctum token and the company it acts for are the owner's to set from
// /invoices/settings; nothing reads them from the environment any more. It is a REGISTRY — any number of
// labelled accounts, one of them selected — and two accounts differing only by company is the normal case,
// because the `company` header silently returns another company's books rather than erroring.
//
// The token is WRITE-ONLY across this boundary: an account carries its label, URL and pinned company, and
// has no field that could carry the token back to the browser.

const CONFIG = '/invoiceshelf/_config';

export type InvoiceShelfHealth = {
  ok: boolean;
  /** False only when no account is stored. It separates "set this up" from "this used to work". */
  configured?: boolean;
  /** Label of the selected account, so a failure names which books did not answer. */
  account?: string;
  version?: string | null;
  company?: number | null;
  error?: string;
};

export type InvoiceShelfAccount = {
  id: number;
  label: string;
  url: string;
  companyId: number | null;
  companyName: string | null;
  version: string | null;
  isActive: boolean;
  lastSeenAt: string | null;
  createdAt: string;
};

export type InvoiceShelfAccounts = { configured: boolean; activeId: number | null; accounts: InvoiceShelfAccount[] };

export type CompanyOption = { id: number; name: string | null };

/**
 * Unwrap a thrown client error into the shape the connection form needs.
 *
 * `needsChoice` is the interesting one: adding an account whose token can see several companies is a 409
 * carrying the list, because nothing is wrong with what was sent — it just is not enough to decide. The form
 * turns that into a picker rather than an error.
 */
export function parseAccountError(err: unknown): { message: string; needsChoice: boolean; companies: CompanyOption[] } {
  const raw = (err as { message?: unknown } | null)?.message;
  const fallback = { message: 'Something went wrong', needsChoice: false, companies: [] as CompanyOption[] };
  if (typeof raw !== 'string' || !raw) return fallback;
  try {
    const body = JSON.parse(raw) as { error?: string; needsChoice?: boolean; companies?: CompanyOption[] };
    return {
      message: body.error || raw.slice(0, 300),
      needsChoice: !!body.needsChoice,
      companies: body.companies ?? [],
    };
  } catch {
    return { ...fallback, message: raw.slice(0, 300) };
  }
}

/**
 * Health, including its failure bodies.
 *
 * `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
 * the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
 * so the throw is turned back into the answer rather than an error state.
 */
export function useInvoiceShelfHealth() {
  const { get } = useClient();
  return useQuery({
    queryKey: [ROOT, 'health'] as const,
    queryFn: async (): Promise<InvoiceShelfHealth> => {
      try {
        return await get<InvoiceShelfHealth>('/invoiceshelf/_health');
      } catch (err) {
        const raw = (err as { message?: unknown } | null)?.message;
        if (typeof raw === 'string') {
          try {
            const body = JSON.parse(raw) as InvoiceShelfHealth;
            if (body && body.ok === false) return body;
          } catch {
            /* not the sidecar's body */
          }
        }
        // Anything else — the platform proxy, auth, the sidecar being down — is a configured instance that is
        // failing, not an unconfigured one. Never offer the setup form on a guess.
        return { ok: false, configured: true, error: parseAccountError(err).message };
      }
    },
    staleTime: STALE_MS,
    retry: false,
  });
}

export function useInvoiceShelfAccounts() {
  const { get } = useClient();
  return useQuery({
    queryKey: [ROOT, 'accounts'] as const,
    queryFn: () => get<InvoiceShelfAccounts>(CONFIG),
    staleTime: STALE_MS,
    retry: false,
  });
}

/** The companies a stored account's token can act for — what the company picker is populated from. */
export function useAccountCompanies(id: number | null) {
  const { get } = useClient();
  return useQuery({
    queryKey: [ROOT, 'accounts', id, 'companies'] as const,
    queryFn: () => get<{ companies: CompanyOption[]; companyId: number | null }>(`${CONFIG}/${id}/companies`),
    enabled: id != null,
    staleTime: STALE_MS,
    retry: false,
  });
}

/**
 * Either a pasted Sanctum token or a sign-in the sidecar trades for one.
 *
 * The password goes to the sidecar, which uses it for the single `/auth/login` call and stores only the token
 * it gets back. It exists because InvoiceShelf 2.4.2 has no screen that issues tokens at all.
 */
export type Credential = { token: string } | { email: string; password: string };

export type AddAccountInput = { label: string; url: string; companyId?: number | null } & Credential;
export type EditAccountInput = { id: number; label?: string; url?: string; companyId?: number | null } & Partial<
  Record<'token' | 'email' | 'password', string>
>;

export function useInvoiceShelfAccountActions() {
  const { post, patch, delete: del } = useClient();
  const qc = useQueryClient();
  // Adding, editing, switching and removing all change what every other query here can even answer — a
  // switch in particular changes the answer to all of them without changing any of their inputs.
  const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });

  const add = useMutation({
    mutationFn: (input: AddAccountInput) => post<{ account: InvoiceShelfAccount }>(CONFIG, input),
    onSuccess: invalidate,
  });

  const edit = useMutation({
    mutationFn: ({ id, ...rest }: EditAccountInput) => patch<{ account: InvoiceShelfAccount }>(`${CONFIG}/${id}`, rest),
    onSuccess: invalidate,
  });

  const activate = useMutation({
    mutationFn: (id: number) => post<InvoiceShelfAccounts>(`${CONFIG}/${id}/activate`, {}),
    onSuccess: invalidate,
  });

  const remove = useMutation({
    mutationFn: (id: number) => del<InvoiceShelfAccounts>(`${CONFIG}/${id}`),
    onSuccess: invalidate,
  });

  return { add, edit, activate, remove };
}
