package com.vexoanalytics.viewshot;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.robolectric.Shadows.shadowOf;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Looper;
import android.view.View;

import androidx.test.core.app.ApplicationProvider;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/**
 * Regression test that locks the fix in PR #130: {@link ViewShot#drawViewOnMainThread} must run
 * {@link View#draw(Canvas)} on the MAIN (UI) thread, never on the capture executor's worker thread.
 *
 * <p>{@code View.draw()} is software rendering that touches the view tree's RenderNodes / display
 * lists owned by the UI thread. Before #130, {@code captureViewImpl()} called it directly on the
 * background capture executor, racing the UI thread's frame and crashing with a Compose/RN child
 * ("Recording currently in progress" / mPreSortedChildren corruption). #130 marshals the draw onto
 * the main looper and blocks the worker until it finishes.
 *
 * <p>This test FAILS against the pre-#130 code: the old {@code view.draw(c)} ran inline on whatever
 * thread called it, so {@code recordedLooper} would be the background thread's (null), not the main
 * looper — the {@code assertSame(main looper)} in {@link #draw_fromBackgroundThread_runsOnMainThread}
 * is exactly the regression it locks.
 *
 * <p>Robolectric's main looper is PAUSED by default, so a runnable posted to it from the background
 * worker does not run until the test thread drains it via {@code shadowOf(mainLooper).idle()}. The
 * test thread itself IS the main thread under Robolectric, which is why the posted draw records the
 * main looper.
 */
@RunWith(RobolectricTestRunner.class)
public class ViewShotMainThreadDrawTest {

  /**
   * A View that records which Looper/Thread its draw() ran on, flags start/finish for ordering
   * assertions, and can be told to throw from draw() to exercise the fail-safe propagation path.
   * draw() deliberately does NOT call super.draw(): the test only cares which thread invoked it.
   */
  private static final class TestView extends View {
    final AtomicReference<Looper> recordedLooper = new AtomicReference<>(null);
    final AtomicReference<Thread> recordedThread = new AtomicReference<>(null);
    final AtomicBoolean drawStarted = new AtomicBoolean(false);
    final AtomicBoolean drawFinished = new AtomicBoolean(false);
    volatile boolean throwFromDraw = false;

    TestView(Context context) {
      super(context);
    }

    @Override
    public void draw(Canvas canvas) {
      drawStarted.set(true);
      recordedLooper.set(Looper.myLooper());
      recordedThread.set(Thread.currentThread());
      if (throwFromDraw) {
        throw new RuntimeException("boom from draw()");
      }
      drawFinished.set(true);
    }
  }

  private TestView newTestView() {
    Context context = ApplicationProvider.getApplicationContext();
    return new TestView(context);
  }

  private static Canvas newCanvas() {
    Bitmap bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888);
    return new Canvas(bitmap);
  }

  /**
   * (a) draw() runs on the MAIN looper and (b) the background caller blocks until it finishes,
   * when {@code drawViewOnMainThread} is invoked off the main thread.
   */
  @Test
  public void draw_fromBackgroundThread_runsOnMainThread() throws Exception {
    final TestView view = newTestView();
    final Canvas canvas = newCanvas();

    final Thread mainThread = Thread.currentThread(); // Robolectric runs the test on the main thread
    final AtomicReference<Thread> callerThread = new AtomicReference<>(null);
    final AtomicBoolean returned = new AtomicBoolean(false);
    // Snapshot, inside the worker right after the call returns, whether draw() had already
    // finished — proves the worker BLOCKED until the main-thread draw completed.
    final AtomicBoolean drawWasFinishedWhenCallReturned = new AtomicBoolean(false);
    final CountDownLatch bgStarted = new CountDownLatch(1);

    Thread bg = new Thread(() -> {
      callerThread.set(Thread.currentThread());
      bgStarted.countDown();
      ViewShot.drawViewOnMainThread(view, canvas);
      drawWasFinishedWhenCallReturned.set(view.drawFinished.get());
      returned.set(true);
    }, "vexo-capture-worker");
    bg.start();
    assertTrue("bg worker did not start", bgStarted.await(5, TimeUnit.SECONDS));

    // Pump the (paused) main looper until the worker's posted draw runs and the worker unblocks.
    long deadlineMs = System.currentTimeMillis() + 5000;
    while (bg.isAlive() && System.currentTimeMillis() < deadlineMs) {
      shadowOf(Looper.getMainLooper()).idle();
      Thread.sleep(10);
    }
    bg.join(3000);
    assertFalse("worker never unblocked — draw() was not marshalled to the main looper", bg.isAlive());

    // draw() actually ran...
    assertTrue("draw() never ran", view.drawStarted.get());
    // ...on the MAIN looper (the regression: pre-#130 this was the worker's looper == null)
    assertSame("view.draw() must run on the main looper", Looper.getMainLooper(), view.recordedLooper.get());
    // ...on the main thread, NOT the background worker
    assertSame("view.draw() must run on the main thread", mainThread, view.recordedThread.get());
    assertNotSame("view.draw() must NOT run on the capture worker thread",
        callerThread.get(), view.recordedThread.get());

    // (b) the worker blocked until the draw completed
    assertTrue("drawViewOnMainThread must return only after view.draw() completes",
        drawWasFinishedWhenCallReturned.get());
    assertTrue(returned.get());
  }

  /**
   * (c) A RuntimeException thrown inside draw() on the main thread propagates back out of
   * {@code drawViewOnMainThread} to the background caller (fail-safe path).
   */
  @Test
  public void draw_throwingOnMainThread_propagatesToCaller() throws Exception {
    final TestView view = newTestView();
    view.throwFromDraw = true;
    final Canvas canvas = newCanvas();

    final AtomicReference<Throwable> thrown = new AtomicReference<>(null);
    final CountDownLatch bgStarted = new CountDownLatch(1);

    Thread bg = new Thread(() -> {
      bgStarted.countDown();
      try {
        ViewShot.drawViewOnMainThread(view, canvas);
      } catch (Throwable t) {
        thrown.set(t);
      }
    }, "vexo-capture-worker");
    bg.start();
    assertTrue(bgStarted.await(5, TimeUnit.SECONDS));

    long deadlineMs = System.currentTimeMillis() + 5000;
    while (bg.isAlive() && System.currentTimeMillis() < deadlineMs) {
      shadowOf(Looper.getMainLooper()).idle();
      Thread.sleep(10);
    }
    bg.join(3000);
    assertFalse(bg.isAlive());

    assertTrue("draw() should have run", view.drawStarted.get());
    Throwable t = thrown.get();
    assertNotNull("draw() failure must propagate to the caller", t);
    assertTrue(t instanceof RuntimeException);
    // #130 wraps the original throwable; the boom must be reachable as the cause.
    assertNotNull("original draw() failure must be preserved as the cause", t.getCause());
    assertEquals("boom from draw()", t.getCause().getMessage());
  }

  /**
   * Inline path: called ON the main thread, {@code drawViewOnMainThread} draws inline (no post,
   * no hang) — {@code Looper.myLooper() == main} short-circuit.
   */
  @Test
  public void draw_onMainThread_drawsInlineWithoutHanging() {
    final TestView view = newTestView();
    final Canvas canvas = newCanvas();

    // We are on the main thread here (Robolectric). No looper idling required; must not block.
    ViewShot.drawViewOnMainThread(view, canvas);

    assertTrue("draw() must run inline on the main thread", view.drawFinished.get());
    assertSame(Looper.getMainLooper(), view.recordedLooper.get());
    assertSame(Thread.currentThread(), view.recordedThread.get());
  }

  /** Sanity: a plain worker thread has no Looper, so recording it would never equal the main looper. */
  @Test
  public void backgroundThread_hasNoLooper() throws Exception {
    final AtomicReference<Looper> workerLooper = new AtomicReference<>(Looper.getMainLooper());
    Thread bg = new Thread(() -> workerLooper.set(Looper.myLooper()));
    bg.start();
    bg.join(5000);
    assertNull("a plain capture worker has no Looper", workerLooper.get());
  }
}
