import type {
  Album,
  AlbumDetail,
  Asset,
  ExploreSection,
  GridAsset,
  MapMarker,
  PeopleResponse,
  Person,
  SearchResponse,
  ServerConfig,
  SharedLink,
  TimeBucket,
  TimeBucketColumns,
} from './shared';
import { useMemo } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { bucketAssets } from './shared';

// Reads for the /photos panels. Everything goes through /api/photos/_officer/*, which is the auth proxy in
// front of the officer-photos sidecar — the browser never learns the Immich URL or its API key.
//
// The timeline is two queries, not one: a cheap bucket INDEX (one row per month) and a per-bucket fetch the
// grid triggers as you scroll. Immich's own web app works this way, and it is the only reason a library of
// 100k assets can render a correct scrollbar without downloading 100k rows first.

const KEY = 'photos';

/** Timeline filters. The same query shape serves the main timeline, favorites, archive, trash and albums. */
export type TimelineFilter = {
  albumId?: string;
  personId?: string;
  tagId?: string;
  isFavorite?: boolean;
  isTrashed?: boolean;
  visibility?: 'timeline' | 'archive';
  withPartners?: boolean;
  withStacked?: boolean;
};

type QueryValues = Record<string, string | number | boolean | undefined>;

const filterQuery = (filter: QueryValues): string => {
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(filter)) {
    if (value !== undefined && value !== null) params.set(key, String(value));
  }
  return params.toString();
};

export function useTimeBuckets(filter: TimelineFilter, enabled = true) {
  const { get } = useClient();
  const query = filterQuery(filter);

  return useQuery({
    queryKey: [KEY, 'buckets', query],
    queryFn: () => get<TimeBucket[]>(`/photos/_officer/timeline/buckets?${query}`),
    enabled,
    staleTime: 60_000,
  });
}

/**
 * Fetch several buckets at once — the months the grid has actually scrolled into view.
 *
 * `useQueries` rather than a loop of `useTimeBucket`, because the number of loaded months grows as you scroll
 * and hooks cannot be called conditionally. Each bucket keeps its own cache entry, so scrolling back up is
 * free and a mutation invalidating ['photos'] refetches only the months still mounted. A month that has
 * already scrolled past does not refetch when you scroll back to it.
 */
export function useBucketAssets(filter: TimelineFilter, buckets: string[]) {
  const { get } = useClient();
  const query = filterQuery(filter);

  const results = useQueries({
    queries: buckets.map((bucket) => ({
      queryKey: [KEY, 'bucket', query, bucket],
      queryFn: () =>
        get<TimeBucketColumns>(`/photos/_officer/timeline/bucket?${query}&timeBucket=${encodeURIComponent(bucket)}`),
      staleTime: 5 * 60_000,
    })),
  });

  return useMemo(() => {
    const map: Record<string, GridAsset[]> = {};
    buckets.forEach((bucket, i) => {
      const cols = results[i]?.data;
      if (cols) map[bucket] = bucketAssets(cols);
    });
    return map;
    // `results` is a fresh array every render; its identity is meaningless. The data is what matters.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [buckets, results.map((r) => r.dataUpdatedAt).join(',')]);
}

export function useAlbums() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'albums'],
    queryFn: () => get<Album[]>('/photos/_officer/albums'),
    staleTime: 30_000,
  });
}

export function useAlbum(id: string | null) {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'album', id],
    queryFn: () => get<AlbumDetail>(`/photos/_officer/albums/${id}`),
    enabled: !!id,
    staleTime: 30_000,
  });
}

export function usePeople() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'people'],
    queryFn: () => get<PeopleResponse>('/photos/_officer/people?withHidden=false&size=1000'),
    staleTime: 60_000,
  });
}

export function usePerson(id: string | null) {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'person', id],
    queryFn: () => get<Person>(`/photos/_officer/people/${id}`),
    enabled: !!id,
    staleTime: 60_000,
  });
}

export function useExplore() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'explore'],
    queryFn: () => get<ExploreSection[]>('/photos/_officer/search/explore'),
    staleTime: 5 * 60_000,
  });
}

export function useSharedLinks() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'shared-links'],
    queryFn: () => get<SharedLink[]>('/photos/_officer/shared-links'),
    staleTime: 30_000,
  });
}

export function useAsset(id: string | null) {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'asset', id],
    queryFn: () => get<Asset>(`/photos/_officer/assets/${id}`),
    enabled: !!id,
    staleTime: 60_000,
  });
}

/**
 * Search. `smart` is Immich's CLIP search ("a dog on a beach") and is what the box does by default; `metadata`
 * matches filenames, paths and EXIF text. They take the same filters and return the same envelope, so the mode
 * is one flag rather than two hooks.
 */
type SearchParams = { query: string; smart: boolean; enabled: boolean };
export function useSearch({ query, smart, enabled }: SearchParams) {
  const { post } = useClient();
  const text = query.trim();

  return useQuery({
    queryKey: [KEY, 'search', smart ? 'smart' : 'metadata', text],
    queryFn: () =>
      smart
        ? post<SearchResponse>('/photos/_officer/search/smart', { query: text, size: 250, withExif: true })
        : post<SearchResponse>('/photos/_officer/search/metadata', {
            originalFileName: text,
            size: 250,
            withExif: true,
            order: 'desc',
          }),
    enabled: enabled && text.length > 0,
    staleTime: 60_000,
  });
}

export type AssetStats = { images: number; videos: number; total: number };

export function useAssetStats() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'stats'],
    queryFn: () => get<AssetStats>('/photos/_officer/assets/statistics'),
    staleTime: 5 * 60_000,
  });
}

/**
 * Map pins. `/map/markers` is NOT bucketed — it returns every geotagged asset in one array, because a marker is
 * six numbers and the whole library is a couple of megabytes at most. Clustering happens in the browser.
 *
 * Note `isArchived`, not `visibility`: the map endpoint kept the old boolean flags when the timeline moved to
 * the `visibility` enum, so this filter deliberately does not match `TimelineFilter`.
 */
export type MapFilter = {
  isArchived?: boolean;
  isFavorite?: boolean;
  withPartners?: boolean;
  withSharedAlbums?: boolean;
  fileCreatedAfter?: string;
  fileCreatedBefore?: string;
};

export function useMapMarkers(filter: MapFilter) {
  const { get } = useClient();
  const query = filterQuery(filter);

  return useQuery({
    queryKey: [KEY, 'map-markers', query],
    queryFn: () => get<MapMarker[]>(`/photos/_officer/map/markers?${query}`),
    staleTime: 5 * 60_000,
  });
}

/** Immich's own map style URLs live in its server config, so the owner's Immich settings pick the basemap. */
export function useServerConfig() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'server-config'],
    queryFn: () => get<ServerConfig>('/photos/_officer/server/config'),
    staleTime: 30 * 60_000,
  });
}

// ── Accounts ──────────────────────────────────────────────────────────────────────────────────────
//
// Immich instances and their API keys are the owner's to set from /photos/settings; nothing reads them from
// the environment any more. It is a REGISTRY — any number of labelled accounts, one of them selected — so
// two keys against the same instance (one per Immich user) is an ordinary thing to have.
//
// The key is WRITE-ONLY across this boundary: an account carries its label, URL and whether it is selected,
// and has no field that could carry the key back to the browser.

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

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

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

/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
export function photosErrorMessage(err: unknown): string {
  const raw = (err as { message?: unknown } | null)?.message;
  if (typeof raw !== 'string' || !raw) return 'Something went wrong';
  try {
    const parsed = JSON.parse(raw) as { error?: unknown };
    if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
  } catch {
    /* plain text */
  }
  return 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 usePhotosHealth() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'health'],
    queryFn: async (): Promise<PhotosHealth> => {
      try {
        return await get<PhotosHealth>('/photos/_health');
      } catch (err) {
        const raw = (err as { message?: unknown } | null)?.message;
        if (typeof raw === 'string') {
          try {
            const body = JSON.parse(raw) as PhotosHealth;
            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: photosErrorMessage(err) };
      }
    },
    staleTime: 60_000,
    retry: false,
  });
}

export function usePhotosAccounts() {
  const { get } = useClient();
  return useQuery({
    queryKey: [KEY, 'accounts'],
    queryFn: () => get<PhotosAccounts>('/photos/_config'),
    staleTime: 60_000,
    retry: false,
  });
}

/**
 * Either a pasted API key or a sign-in the sidecar trades for one.
 *
 * The password goes to the sidecar, which logs in, mints an all-permissions key, closes the session again and
 * stores only the key. It never reaches Postgres and never comes back to the browser.
 */
export type PhotosCredential = { apiKey: string } | { email: string; password: string };

export type AddPhotosAccountInput = { label: string; url: string } & PhotosCredential;
export type EditPhotosAccountInput = { id: number; label?: string; url?: string } & Partial<
  Record<'apiKey' | 'email' | 'password', string>
>;

export function usePhotosAccountActions() {
  const { post, patch, delete: del } = useClient();
  const qc = useQueryClient();
  // Adding, editing, switching and removing all change what every other query in this workspace 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: [KEY] });

  const add = useMutation({
    mutationFn: (input: AddPhotosAccountInput) => post<{ account: PhotosAccount }>('/photos/_config', input),
    onSuccess: invalidate,
  });

  const edit = useMutation({
    mutationFn: ({ id, ...rest }: EditPhotosAccountInput) =>
      patch<{ account: PhotosAccount }>(`/photos/_config/${id}`, rest),
    onSuccess: invalidate,
  });

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

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

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