import React, { useEffect, useRef, useState } from 'react';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
import { useAppActive } from '../../hooks/useAppActive';
import * as css from './SyncIndicator.css';

// Suppress sub-100ms green flashes during fast transient state cycles
// (e.g. one /sync request blips Reconnecting then immediately recovers to
// Syncing — without the delay the bar would briefly appear at whatever
// position the slide animation happened to be at). 100ms keeps the bar
// responsive enough that real transitions land before the user gives up.
//
// Trade-off: combined with the cycle-complete hide below, transients in
// the 100-1800ms window now block the bar for a full slide cycle (~2s
// of visible bar) instead of cutting at the actual recovery moment.
// Deliberate choice — graceful animation > strict timing accuracy.
const PROGRESS_FADE_IN_DELAY_MS = 100;

// Fallback hide deadline if `animationiteration` never fires — covers
// any compositor freeze that drops the event (e.g. tab backgrounded
// mid-cycle and just foregrounded). 2100ms gives ~300ms slack over the
// 1.8s slide-cycle. The reduced-motion path skips this branch entirely.
const PROGRESS_HIDE_FALLBACK_MS = 2100;

// On detected resume, hold the visual at "progress" (green) for this long if
// the SDK is in `Error`. Worst-case keep-alive backoff in matrix-js-sdk is
// ~5–10s (jittered), so 10s is wide enough that we don't lose the race and
// flash red on a stale Error that the SDK is about to clear on its own.
const RESUME_GRACE_MS = 10_000;

// Below this threshold, ignore the inactive period — it's a notification
// pulldown, brief alt-tab, or system permission prompt, not a real lock /
// background. 3s is conservative: anything shorter than the SDK's first
// retry attempt is uninteresting.
const RESUME_HIDDEN_THRESHOLD_MS = 3_000;

type SyncIndicatorProps = {
  mx: MatrixClient;
};

type Visual = 'hidden' | 'progress' | 'error';

// Steady-state mapping. The real SDK state machine has more values; this
// collapses the cases that matter for the indicator:
//   - Syncing / Stopped → connection is healthy or terminating → hidden
//   - Error             → SDK gave up after 3 retries → red
//   - everything else   → SDK is in transit (Reconnecting / Catchup / the
//                         unrealistic post-mount null/Prepared) → green
const stateToVisual = (state: SyncState | null): Visual => {
  if (state === SyncState.Error) return 'error';
  if (state === SyncState.Syncing || state === SyncState.Stopped) return 'hidden';
  return 'progress';
};

export function SyncIndicator({ mx }: SyncIndicatorProps) {
  const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
  // Timestamp until which a latched `Error` should be demoted to `progress`
  // (green). Set on a real resume from background; cleared either by the
  // expiry timer below or by the first `Syncing` event after resume.
  const [resumeGraceUntil, setResumeGraceUntil] = useState(0);

  useEffect(() => {
    // Re-read after the listener is wired to close the gap between the
    // useState initializer (synchronous render) and the effect mount
    // (post-paint). A Sync event in that ~16ms window would otherwise be
    // dropped.
    setSyncState(mx.getSyncState());
    const handler = (state: SyncState) => {
      setSyncState(state);
      // First Syncing after resume — recovery confirmed, drop the grace
      // immediately rather than waiting out the timer. Avoids leaving the
      // bar green for the residual seconds when the SDK already recovered.
      if (state === SyncState.Syncing) {
        setResumeGraceUntil(0);
      }
    };
    mx.on(ClientEvent.Sync, handler);
    return () => {
      mx.removeListener(ClientEvent.Sync, handler);
    };
  }, [mx]);

  // Lifecycle: on resume from a long-hidden state (phone unlock, app
  // foregrounded), kick the SDK out of any latched Error with
  // retryImmediately() and set a grace window during which the indicator
  // shows green ("reconnecting") instead of red. Without this, an Android
  // screen-off period kills the in-flight long-poll, the SDK retries 3×
  // during the throttled JS window, lands on Error, and the user sees a
  // ~5s red flash on unlock before the SDK's keep-alive jitter recovers.
  //
  // Foreground/background signal comes from the shared `useAppActive`
  // hook (Android+iOS use pause/resume for ms latency; web uses
  // appStateChange/visibilitychange).
  const active = useAppActive();
  const hiddenAtRef = useRef<number | null>(null);
  useEffect(() => {
    if (!active) {
      hiddenAtRef.current = performance.now();
      return;
    }
    if (hiddenAtRef.current === null) return;
    const gap = performance.now() - hiddenAtRef.current;
    hiddenAtRef.current = null;
    if (gap < RESUME_HIDDEN_THRESHOLD_MS) return;
    setResumeGraceUntil(performance.now() + RESUME_GRACE_MS);
    // retryImmediately is idempotent: a no-op when the SDK isn't waiting
    // on a scheduled retry (i.e. healthy Syncing). Cheap to call.
    if (mx.getSyncState() !== SyncState.Syncing) {
      mx.retryImmediately();
    }
  }, [active, mx]);

  // Auto-clear the grace window when its timer expires. Skipped if the
  // first-Syncing handler above already cleared it.
  useEffect(() => {
    if (resumeGraceUntil === 0) return undefined;
    const remaining = resumeGraceUntil - performance.now();
    if (remaining <= 0) {
      setResumeGraceUntil(0);
      return undefined;
    }
    const timer = setTimeout(() => setResumeGraceUntil(0), remaining);
    return () => clearTimeout(timer);
  }, [resumeGraceUntil]);

  const baseVisual = stateToVisual(syncState);
  // Grace window only overrides Error → progress. Other transitions
  // (Syncing→hidden, etc) fire normally — the user's recovery is real.
  const visual: Visual = resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;

  // Three logical phases for the green progress bar (mirrors the bot
  // widget loading bar):
  //   - hidden  (showProgress=false, pendingProgressHide=false)
  //   - visible (showProgress=true,  pendingProgressHide=false)
  //   - closing (showProgress=true,  pendingProgressHide=true) →
  //              progress→hidden landed mid-sweep, waiting for the
  //              animationiteration boundary to land before snapping
  //              invisible so the user always sees a complete left-to-
  //              right sweep, never a mid-stroke cut.
  // Cycle-complete is only applied for progress→hidden. progress→error
  // skips it because we want the red layer to take over without a half-
  // faded green sweep visually mixing on top of it.
  const [showProgress, setShowProgress] = useState(false);
  const [pendingProgressHide, setPendingProgressHide] = useState(false);

  useEffect(() => {
    if (visual === 'progress') {
      setPendingProgressHide(false);
      if (showProgress) return undefined;
      const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
      return () => clearTimeout(timer);
    }
    if (visual === 'hidden' && showProgress) {
      // Reduced-motion: animation is off, no iteration will land,
      // parking a static green bar for ~2s isn't graceful — it's just
      // a stuck bar. Snap invisible immediately.
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        setShowProgress(false);
        setPendingProgressHide(false);
        return undefined;
      }
      // Sync recovered while bar was mid-sweep — defer the hide to the
      // next iteration boundary. Fallback force-hides if the event
      // never lands (compositor freeze on backgrounded tab).
      setPendingProgressHide(true);
      const fallback = setTimeout(() => {
        setShowProgress(false);
        setPendingProgressHide(false);
      }, PROGRESS_HIDE_FALLBACK_MS);
      return () => clearTimeout(fallback);
    }
    // Either visual === 'error' (red layer takes over, drop green now)
    // or visual === 'hidden' && !showProgress (already hidden, no-op).
    setShowProgress(false);
    setPendingProgressHide(false);
    return undefined;
  }, [visual, showProgress]);

  const handleProgressIteration = () => {
    if (pendingProgressHide) {
      setShowProgress(false);
      setPendingProgressHide(false);
    }
  };

  const showError = visual === 'error';

  // Both layers are always mounted; opacity controls visibility with a
  // 250ms transition (set on barBase). `animationPlayState: 'paused'` on
  // the green layer when invisible avoids continuous compositor work —
  // `opacity: 0` does NOT pause CSS animations in any major browser.
  //
  // On progress→error we snap the green to opacity:0 with no transition
  // (`transition: 'none'`). The default 250ms fade-out would otherwise
  // cross-fade with the red layer's 250ms fade-in — both stack at the
  // same bottom edge, both at intermediate alpha, producing a muddy
  // green-on-red blend at the exact moment we're trying to alarm the
  // user. The `paused` animation also freezes the green at whatever
  // mid-cycle position it was at, so the cross-fade looks like a static
  // green smear bleeding into a static red bar. Snap-hide kills it.
  return (
    <div className={css.root} aria-hidden>
      <div
        className={css.barProgress}
        style={{
          opacity: showProgress ? 1 : 0,
          animationPlayState: showProgress ? 'running' : 'paused',
          transition: showError ? 'none' : undefined,
        }}
        onAnimationIteration={handleProgressIteration}
      />
      <div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
    </div>
  );
}
