'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowDownTrayIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import Spinner from '@/components/ui/Spinner';

// The mobile save flow. On a phone `<a download>` is a dead end for someone
// who wants the photo in their camera roll: iOS is WebKit everywhere, so it
// files the download under Files > Downloads and nothing else, and Android
// drops it in Downloads where Google Photos may or may not pick it up. The
// only route into the device gallery on either platform is the Web Share
// API, which hands the file to the native share sheet ("Save Image" on iOS,
// "Photos" on Android).
//
// So on mobile the download button opens this sheet instead of downloading,
// and the default row shares the 2048px preview — a file a phone screen
// cannot tell from the original, at a fraction of the bytes. The original
// stays one tap away for anyone who actually wants it.

export interface SavePhoto {
  id: string;
  /** Preview download URL — the same URL the lightbox renders, so this is
   *  usually answered from the HTTP cache rather than the network. */
  previewUrl: string;
  originalUrl: string;
  /** Byte sizes from the gallery response, used to label the rows. */
  previewSize?: number;
  originalSize?: number;
}

type RowKey = 'preview' | 'original';
type RowStatus = 'idle' | 'loading' | 'ready' | 'error';

interface RowState {
  status: RowStatus;
  /** 0..1, only tracked for the original's determinate progress bar. */
  progress: number;
}

const IDLE: RowState = { status: 'idle', progress: 0 };

/**
 * True on finger-first devices, i.e. where a plain download lands in
 * Downloads instead of the photo library and this sheet is worth showing.
 * Feature detection only — user agent strings lie, and iPad has been
 * claiming to be a Mac for years.
 *
 * Deliberately NOT gated on `navigator.share`. Web Share only exists in a
 * secure context, so requiring it here made the sheet disappear entirely on
 * any plain-HTTP origin (a phone hitting the dev server over the LAN sees
 * `navigator.share === undefined`) and on Firefox for Android, which has no
 * file sharing. Both of those should still get the sheet — their rows just
 * fall back to a download. Whether a given row shares or downloads is
 * decided per file, at tap time, by canShareFile() below.
 */
export function useMobileSave(): boolean {
  const [mobile, setMobile] = useState(false);

  useEffect(() => {
    // Comma = OR. `hover: none` catches touch devices that report a fine
    // pointer because a stylus is paired.
    const mq = window.matchMedia('(pointer: coarse), (hover: none)');
    const sync = () => setMobile(mq.matches);
    sync();
    mq.addEventListener('change', sync);
    return () => mq.removeEventListener('change', sync);
  }, []);

  return mobile;
}

function canShareFile(file: File): boolean {
  return typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
}

/** Reads the server's Content-Disposition name (spanglish-<event>-<n>.jpg). */
function filenameFrom(header: string | null, fallback: string): string {
  const match = header?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
  return match ? decodeURIComponent(match[1]) : fallback;
}

function formatBytes(bytes: number | undefined, locale: string): string | null {
  if (!bytes || bytes <= 0) return null;
  const mb = bytes / 1_000_000;
  if (mb < 1) {
    return `${Math.round(bytes / 1000).toLocaleString(locale)} kB`;
  }
  return `${mb.toLocaleString(locale, { maximumFractionDigits: 1 })} MB`;
}

/**
 * Fetches a URL into a File. onProgress is only wired up when the caller
 * wants a determinate bar — reading the body in chunks costs an extra copy,
 * which is not worth it for the preview that is normally already cached.
 */
async function fetchAsFile(
  url: string,
  signal: AbortSignal,
  onProgress?: (fraction: number) => void
): Promise<File> {
  // No `cache` option: the default is what lets the lightbox's already
  // rendered preview be reused instead of refetched.
  const res = await fetch(url, { credentials: 'same-origin', signal });
  if (!res.ok) throw new Error(`Download failed (${res.status})`);

  const type = res.headers.get('Content-Type') || 'image/jpeg';
  const name = filenameFrom(res.headers.get('Content-Disposition'), 'spanglish-photo.jpg');
  const total = Number(res.headers.get('Content-Length') || 0);

  let blob: Blob;
  if (onProgress && total > 0 && res.body) {
    const reader = res.body.getReader();
    const chunks: Uint8Array[] = [];
    let received = 0;
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      received += value.length;
      onProgress(Math.min(1, received / total));
    }
    blob = new Blob(chunks as BlobPart[], { type });
  } else {
    blob = await res.blob();
  }
  return new File([blob], name, { type: blob.type || type });
}

/** Fallback for browsers without file sharing: a plain download. */
function saveViaAnchor(file: File) {
  const objectUrl = URL.createObjectURL(file);
  const a = document.createElement('a');
  a.href = objectUrl;
  a.download = file.name;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}

interface SaveSheetProps {
  /** The photo to save; null keeps the sheet closed. */
  photo: SavePhoto | null;
  onClose: () => void;
}

export default function SaveSheet({ photo, onClose }: SaveSheetProps) {
  if (!photo) return null;
  // Keyed by photo so a sheet opened for a different photo starts clean
  // instead of inheriting the previous one's fetched file.
  return <SaveSheetPanel key={photo.id} photo={photo} onClose={onClose} />;
}

function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) {
  const { t, locale } = useLanguage();
  const [rows, setRows] = useState<Record<RowKey, RowState>>({
    preview: IDLE,
    original: IDLE,
  });

  // Resolved files live in a ref as well as state: the tap handler has to
  // read them *synchronously*. iOS rejects navigator.share() with
  // NotAllowedError if anything is awaited between the tap and the call,
  // so there is no chance to read them out of a promise first.
  const files = useRef<Partial<Record<RowKey, File>>>({});
  const aborter = useRef<AbortController>(new AbortController());
  const closed = useRef(false);

  const setRow = useCallback((key: RowKey, patch: Partial<RowState>) => {
    if (closed.current) return;
    setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
  }, []);

  const prepare = useCallback(
    async (key: RowKey): Promise<File | null> => {
      setRow(key, { status: 'loading', progress: 0 });
      try {
        const file = await fetchAsFile(
          key === 'preview' ? photo.previewUrl : photo.originalUrl,
          aborter.current.signal,
          // Only the original gets a determinate bar; the preview is
          // normally cached and resolves before a spinner would even paint.
          key === 'original' ? (progress) => setRow(key, { progress }) : undefined
        );
        files.current[key] = file;
        setRow(key, { status: 'ready', progress: 1 });
        return file;
      } catch (err) {
        // Aborting is how the sheet closes and how the user dismisses the
        // native share sheet — neither is an error worth reporting.
        if ((err as Error)?.name === 'AbortError') return null;
        setRow(key, { status: 'error', progress: 0 });
        return null;
      }
    },
    [photo.previewUrl, photo.originalUrl, setRow]
  );

  // Nothing is fetched until the sheet is open — not on lightbox open, not
  // on swipe, not on scroll. Browsing the whole gallery without tapping
  // save costs exactly the thumbs and previews already on screen.
  useEffect(() => {
    // The controller is created here, not at render: closing the sheet
    // aborts it, so a re-run of this effect (React StrictMode double-invokes
    // it in development) has to start from a fresh, unaborted one.
    closed.current = false;
    const controller = new AbortController();
    aborter.current = controller;
    void prepare('preview');
    return () => {
      closed.current = true;
      controller.abort();
    };
  }, [prepare]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  const deliver = (file: File) => {
    if (canShareFile(file)) {
      // Called with nothing awaited in front of it — see the ref above.
      navigator
        .share({ files: [file] })
        .then(() => onClose())
        .catch((err: Error) => {
          // AbortError just means the share sheet was dismissed.
          if (err?.name !== 'AbortError') saveViaAnchor(file);
        });
      return;
    }
    saveViaAnchor(file);
    onClose();
  };

  const onRowTap = (key: RowKey) => {
    const ready = files.current[key];
    if (ready) {
      deliver(ready);
      return;
    }
    if (rows[key].status === 'loading') return;
    // Not fetched yet (or it failed): fetch now and stop. The row goes back
    // to tappable when it resolves, so the share happens on a fresh
    // gesture rather than a stale one iOS would refuse.
    void prepare(key);
  };

  const sizes = {
    preview: formatBytes(photo.previewSize, locale),
    original: formatBytes(photo.originalSize, locale),
  };

  return (
    <div
      className="fixed inset-0 z-[60] flex flex-col justify-end"
      role="dialog"
      aria-modal="true"
      aria-label={t('gallery.save.sheetTitle')}
    >
      <button
        type="button"
        aria-label={t('gallery.save.cancel')}
        className="absolute inset-0 bg-black/60 motion-safe:animate-sheet-fade"
        onClick={onClose}
      />
      <div className="relative w-full sm:max-w-md sm:mx-auto bg-white rounded-t-2xl sm:rounded-2xl sm:mb-4 shadow-xl p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] motion-safe:animate-sheet-rise">
        <div className="mx-auto mb-2 mt-1 h-1 w-10 rounded-full bg-gray-300" aria-hidden />

        <SaveRow
          icon={<PhotoIcon className="w-6 h-6 text-primary-dark" />}
          label={t('gallery.save.photo')}
          hint={t('gallery.save.photoHint')}
          size={sizes.preview}
          state={rows.preview}
          statusLabels={t}
          onTap={() => onRowTap('preview')}
        />
        <SaveRow
          icon={<ArrowDownTrayIcon className="w-6 h-6 text-primary-dark" />}
          label={t('gallery.save.original')}
          hint={t('gallery.save.originalHint')}
          size={sizes.original}
          state={rows.original}
          statusLabels={t}
          showProgress
          onTap={() => onRowTap('original')}
        />

        <button
          type="button"
          onClick={onClose}
          className="mt-1 w-full rounded-xl py-3 text-center font-medium text-gray-600 hover:bg-gray-50 active:bg-gray-100"
        >
          {t('gallery.save.cancel')}
        </button>
      </div>
    </div>
  );
}

function SaveRow({
  icon,
  label,
  hint,
  size,
  state,
  statusLabels,
  showProgress,
  onTap,
}: {
  icon: React.ReactNode;
  label: string;
  hint: string;
  size: string | null;
  state: RowState;
  statusLabels: (key: string) => string;
  showProgress?: boolean;
  onTap: () => void;
}) {
  const loading = state.status === 'loading';
  // The subtitle carries the row's state, so a slow fetch explains itself
  // without the row ever becoming untappable for good.
  const subtitle =
    state.status === 'loading'
      ? statusLabels('gallery.save.preparing')
      : state.status === 'error'
        ? statusLabels('gallery.save.failed')
        : [hint, size].filter(Boolean).join(' · ');

  return (
    <button
      type="button"
      onClick={onTap}
      aria-busy={loading}
      className="relative w-full overflow-hidden rounded-xl px-4 py-3 text-left flex items-center gap-3 hover:bg-gray-50 active:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow"
    >
      <span className="shrink-0">
        {loading ? (
          // Spinner defaults to the white ring used over photos; this one
          // sits on the sheet's white surface.
          <Spinner className="w-6 h-6 border-gray-300 border-t-primary-dark" />
        ) : (
          icon
        )}
      </span>
      <span className="min-w-0">
        <span className="block font-medium text-primary-dark">{label}</span>
        <span
          className={`block text-sm ${state.status === 'error' ? 'text-red-600' : 'text-gray-500'}`}
        >
          {subtitle}
        </span>
      </span>
      {showProgress && loading && (
        <span className="absolute inset-x-0 bottom-0 h-1 bg-gray-200" aria-hidden>
          <span
            className="block h-full bg-primary-yellow transition-[width] duration-150"
            style={{ width: `${Math.round(state.progress * 100)}%` }}
          />
        </span>
      )}
    </button>
  );
}
