// Shared types, constants and pure helpers for the /photos workspace panels.
//
// Unlike the Headscale panels — whose sidecar reshapes Headscale's API into Officer's own types — the photos
// sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
// generated from the LIVE instance's OpenAPI document (`GET /api/spec.json`, Immich 3.0.3), not from memory:
// several of them changed shape in 3.0 (`isArchived` became `visibility`, and the timeline bucket response
// became columnar), so anything written from an older mental model would be subtly wrong.

export const PHOTOS_SECTIONS = [
  { id: 'timeline', label: 'Photos' },
  { id: 'explore', label: 'Explore' },
  { id: 'map', label: 'Map' },
  { id: 'search', label: 'Search' },
  { id: 'albums', label: 'Albums' },
  { id: 'people', label: 'People' },
  { id: 'favorites', label: 'Favorites' },
  { id: 'archive', label: 'Archive' },
  { id: 'sharing', label: 'Sharing' },
  { id: 'trash', label: 'Trash' },
  { id: 'settings', label: 'Connection' },
] as const;

export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];

/** Where /photos lands, and where an unrecognised section redirects to. */
export const DEFAULT_PHOTOS_SECTION: PhotosSectionId = 'timeline';

export const isPhotosSection = (value: string | undefined): value is PhotosSectionId =>
  PHOTOS_SECTIONS.some((s) => s.id === value);

/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
export const photosSectionPath = (id: PhotosSectionId) => `/photos/${id}`;

/** Which asset is open in the lightbox — `?asset=<id>`. */
export const ASSET_PARAM = 'asset';
/** Which album is open — `?album=<id>`. */
export const ALBUM_PARAM = 'album';
/** Which person is open — `?person=<id>`. */
export const PERSON_PARAM = 'person';
/** The search text — `?q=…`. */
export const QUERY_PARAM = 'q';

// ── Wire types ────────────────────────────────────────────────────────────────────────────────────

export type AssetVisibility = 'archive' | 'timeline' | 'hidden' | 'locked';
export type AssetType = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER';

export type ExifInfo = {
  make?: string | null;
  model?: string | null;
  lensModel?: string | null;
  exifImageWidth?: number | null;
  exifImageHeight?: number | null;
  fileSizeInByte?: number | null;
  dateTimeOriginal?: string | null;
  fNumber?: number | null;
  focalLength?: number | null;
  iso?: number | null;
  exposureTime?: string | null;
  latitude?: number | null;
  longitude?: number | null;
  city?: string | null;
  state?: string | null;
  country?: string | null;
  description?: string | null;
  rating?: number | null;
};

export type Asset = {
  id: string;
  type: AssetType;
  thumbhash: string | null;
  localDateTime: string;
  fileCreatedAt: string;
  /** Milliseconds, null for stills. It was an `HH:MM:SS.mmm` string before Immich 3.0 — it is a number now. */
  duration: number | null;
  livePhotoVideoId?: string | null;
  width: number;
  height: number;
  ownerId: string;
  originalFileName: string;
  originalPath: string;
  originalMimeType?: string;
  isFavorite: boolean;
  isArchived: boolean;
  isTrashed: boolean;
  isOffline: boolean;
  visibility: AssetVisibility;
  exifInfo?: ExifInfo;
  people?: Person[];
  stack?: { id: string; primaryAssetId: string; assetCount: number } | null;
};

export type Album = {
  id: string;
  albumName: string;
  description: string;
  createdAt: string;
  updatedAt: string;
  albumThumbnailAssetId: string | null;
  shared: boolean;
  hasSharedLink: boolean;
  assetCount: number;
  startDate?: string;
  endDate?: string;
  albumUsers: { user: { id: string; name: string; email: string } }[];
};

/** `GET /albums/{id}` returns the album with its assets attached. */
export type AlbumDetail = Album & { assets?: Asset[] };

export type Person = {
  id: string;
  name: string;
  birthDate: string | null;
  thumbnailPath: string;
  isHidden: boolean;
  isFavorite?: boolean;
};

export type PeopleResponse = { total: number; hidden: number; people: Person[]; hasNextPage?: boolean };

export type SearchResponse = {
  assets: { total: number; count: number; items: Asset[]; nextPage: string | null };
  albums: { total: number; count: number; items: Album[] };
};

export type ExploreItem = { value: string; data: Asset };
export type ExploreSection = { fieldName: string; items: ExploreItem[] };

export type SharedLink = {
  id: string;
  key: string;
  type: string;
  album?: Album | null;
  assets: Asset[];
  description?: string | null;
  expiresAt: string | null;
  createdAt: string;
  allowDownload: boolean;
  allowUpload: boolean;
};

export type TimeBucket = { timeBucket: string; count: number };

/** `GET /map/markers` — one row per geotagged asset. Deliberately tiny: the whole library fits in one response. */
export type MapMarker = { id: string; lat: number; lon: number; city: string | null; country: string | null };

/**
 * The slice of `GET /server/config` this UI needs.
 *
 * The map style is Immich's own, fetched by the browser straight from `tiles.immich.cloud` — the same place
 * Immich's web app gets it, and the only part of /photos that talks to anything other than Officer. It is not
 * proxied because a vector basemap is thousands of tile requests that have nothing to do with the library.
 */
export type ServerConfig = { mapDarkStyleUrl: string; mapLightStyleUrl: string; version?: string };

/**
 * `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
 * because a month of assets is tens of thousands of near-identical objects and the array-of-arrays form is a
 * fraction of the JSON. Zip it with `bucketAssets()` before touching it.
 */
export type TimeBucketColumns = {
  id: string[];
  ownerId: string[];
  ratio: number[];
  isFavorite: boolean[];
  visibility: AssetVisibility[];
  isTrashed: boolean[];
  isImage: boolean[];
  thumbhash: (string | null)[];
  createdAt: string[];
  fileCreatedAt: string[];
  localOffsetHours: number[];
  /** Milliseconds, null for stills — same units as `Asset.duration`. */
  duration: (number | null)[];
  stack?: (string[] | null)[];
  projectionType: (string | null)[];
  livePhotoVideoId: (string | null)[];
  city?: (string | null)[];
  country?: (string | null)[];
};

/** The minimum a tile needs to draw itself. Both the columnar timeline and a full Asset reduce to this. */
export type GridAsset = {
  id: string;
  /** width / height. The whole point of the columnar payload: layout without loading a single image. */
  ratio: number;
  isFavorite: boolean;
  isImage: boolean;
  isTrashed: boolean;
  /** Milliseconds, null for stills. */
  duration: number | null;
  livePhotoVideoId: string | null;
  fileCreatedAt: string;
  stackCount: number;
};

export function bucketAssets(cols: TimeBucketColumns): GridAsset[] {
  return cols.id.map((id, i) => ({
    id,
    ratio: cols.ratio[i] || 1,
    isFavorite: cols.isFavorite[i] ?? false,
    isImage: cols.isImage[i] ?? true,
    isTrashed: cols.isTrashed[i] ?? false,
    duration: cols.duration?.[i] ?? null,
    livePhotoVideoId: cols.livePhotoVideoId?.[i] ?? null,
    fileCreatedAt: cols.fileCreatedAt[i] ?? '',
    // stack is [stackId, assetCount] when present.
    stackCount: Number(cols.stack?.[i]?.[1] ?? 0),
  }));
}

export const toGridAsset = (a: Asset): GridAsset => ({
  id: a.id,
  ratio: a.height ? a.width / a.height : 1,
  isFavorite: a.isFavorite,
  isImage: a.type === 'IMAGE',
  isTrashed: a.isTrashed,
  duration: a.duration ?? null,
  livePhotoVideoId: a.livePhotoVideoId ?? null,
  fileCreatedAt: a.fileCreatedAt,
  stackCount: a.stack?.assetCount ?? 0,
});

// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
//
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape
// hatch userMiddleware already supports for <audio>/<video> and the one the music app uses for cover art.
// The photos sidecar strips the token before forwarding, so it never reaches Immich or its access log.

const withToken = (url: string, token: string | null) =>
  token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;

export const PHOTOS_API = '/api/photos/_officer';

/** `thumbnail` is the grid tile (~250px); `preview` is the lightbox; `fullsize` is the decoded original. */
export const thumbUrl = (id: string, token: string | null, size: 'thumbnail' | 'preview' | 'fullsize' = 'thumbnail') =>
  withToken(`${PHOTOS_API}/assets/${id}/thumbnail?size=${size}`, token);

export const originalUrl = (id: string, token: string | null) =>
  withToken(`${PHOTOS_API}/assets/${id}/original`, token);

export const videoUrl = (id: string, token: string | null) =>
  withToken(`${PHOTOS_API}/assets/${id}/video/playback`, token);

export const personThumbUrl = (id: string, token: string | null) =>
  withToken(`${PHOTOS_API}/people/${id}/thumbnail`, token);

// ── Formatting ────────────────────────────────────────────────────────────────────────────────────

export const formatBytes = (bytes: number | null | undefined): string => {
  if (!bytes) return '—';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let value = bytes;
  let unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
};

/**
 * Milliseconds → the grid badge's `M:SS` (or `H:MM:SS` when it's long).
 *
 * Immich 3.0 sends durations as an integer count of milliseconds. Older versions sent an `HH:MM:SS.mmm`
 * string, which is worth knowing because every stale example on the internet still shows that form — and
 * `'…'.split(':')` on a number is a render-time TypeError, not a wrong label.
 */
export const formatDuration = (ms: number | null | undefined): string | null => {
  if (!ms || !Number.isFinite(ms)) return null;
  const total = Math.floor(ms / 1000);
  if (total <= 0) return null;
  const pad = (n: number) => String(n).padStart(2, '0');
  const hours = Math.floor(total / 3600);
  const minutes = Math.floor((total % 3600) / 60);
  const seconds = total % 60;
  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
};

const MONTH_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const LONG_DATE = new Intl.DateTimeFormat(undefined, {
  weekday: 'long',
  day: 'numeric',
  month: 'long',
  year: 'numeric',
});
const SHORT_DATE = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
const TIME = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });

/**
 * `2026-08-01` → `August 2026`.
 *
 * Parsed by hand, not with `new Date(bucket)`: a bare `YYYY-MM-DD` is read as UTC midnight, so formatting it
 * in a timezone west of UTC lands on the last day of the PREVIOUS month and the header reads July.
 */
export const formatBucket = (bucket: string): string => {
  const [year, month] = bucket.split('-').map(Number);
  if (!year || !month) return bucket;
  return MONTH_YEAR.format(new Date(year, month - 1, 1));
};

export const formatLongDate = (iso: string | null | undefined): string => {
  if (!iso) return '—';
  const date = new Date(iso);
  return Number.isNaN(date.getTime()) ? '—' : LONG_DATE.format(date);
};

export const formatShortDate = (iso: string | null | undefined): string => {
  if (!iso) return '—';
  const date = new Date(iso);
  return Number.isNaN(date.getTime()) ? '—' : SHORT_DATE.format(date);
};

export const formatTime = (iso: string | null | undefined): string => {
  if (!iso) return '';
  const date = new Date(iso);
  return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
};

/** `2020-01-05` + `2020-03-11` → `5 Jan 2020 – 11 Mar 2020`, collapsing to one when they match. */
export const formatDateRange = (start?: string, end?: string): string | null => {
  if (!start && !end) return null;
  const a = start ? formatShortDate(start) : null;
  const b = end ? formatShortDate(end) : null;
  if (a && b && a !== b) return `${a} – ${b}`;
  return a ?? b;
};

// ── Justified rows ────────────────────────────────────────────────────────────────────────────────

export type LaidOutTile<T> = { item: T; width: number; height: number };
export type LaidOutRow<T> = { tiles: LaidOutTile<T>[]; height: number };

type JustifyParams<T> = {
  items: T[];
  ratioOf: (item: T) => number;
  containerWidth: number;
  targetHeight: number;
  gap: number;
};

/**
 * Immich's signature layout: variable-width tiles packed into rows of equal height, each row filling the
 * container exactly, so nothing is cropped and there are no ragged gaps.
 *
 * Greedy — take tiles at the target height until the row overflows, then scale the row down to fit. The last
 * row is left at the target height rather than stretched, because stretching four holiday photos across a
 * 2000px viewport makes them enormous.
 */
export function justifyRows<T>({
  items,
  ratioOf,
  containerWidth,
  targetHeight,
  gap,
}: JustifyParams<T>): LaidOutRow<T>[] {
  if (containerWidth <= 0 || items.length === 0) return [];

  // A panorama can be wider than the container on its own; clamp so one asset can't own a 12-wide row. The
  // SAME clamped value has to drive both the row's ratio sum and the tile's width, or the row is scaled to fit
  // a total it then overflows.
  const clampRatio = (item: T) => Math.min(Math.max(ratioOf(item) || 1, 0.2), 5);

  const rows: LaidOutRow<T>[] = [];
  let current: T[] = [];
  let ratioSum = 0;

  const flush = (stretch: boolean) => {
    if (current.length === 0) return;
    const available = containerWidth - gap * (current.length - 1);
    const height = stretch ? available / ratioSum : targetHeight;
    rows.push({
      height,
      tiles: current.map((item) => ({ item, width: Math.max(1, clampRatio(item) * height), height })),
    });
    current = [];
    ratioSum = 0;
  };

  for (const item of items) {
    const ratio = clampRatio(item);
    current.push(item);
    ratioSum += ratio;
    const width = ratioSum * targetHeight + gap * (current.length - 1);
    if (width >= containerWidth) flush(true);
  }
  flush(false);

  return rows;
}

/** Estimated pixel height of an unloaded bucket, so the scrollbar is roughly honest before it loads. */
type EstimateParams = { count: number; containerWidth: number; rowHeight: number; gap: number };
export const estimateBucketHeight = ({ count, containerWidth, rowHeight, gap }: EstimateParams): number => {
  const perRow = Math.max(1, Math.round(containerWidth / (rowHeight * 1.35)));
  const rows = Math.ceil(count / perRow);
  return rows * (rowHeight + gap);
};
