package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class PooledPlaywrightExamSprintReportPdfGeneratorTest {

    @Test
    void constructorCreatesPoolSizeWorkers() {
        FakeWorkerFactory factory = new FakeWorkerFactory();

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(3, factory)) {
            assertThat(factory.workers()).hasSize(3);
            assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isZero());
        }
    }

    @Test
    void generateReturnsSuccessfulWorkerToPoolForReuse() {
        FakeWorkerFactory factory = new FakeWorkerFactory();

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
            assertThat(asString(generator.generate("first"))).isEqualTo("worker-1:first");
            assertThat(asString(generator.generate("second"))).isEqualTo("worker-1:second");

            assertThat(factory.workers()).hasSize(1);
            assertThat(factory.worker(0).htmlContents()).containsExactly("first", "second");
        }
    }

    @Test
    void generateEmitsStructuredPoolLogsForSuccessPath() {
        FakeWorkerFactory factory = new FakeWorkerFactory();
        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        logger.addAppender(appender);

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofMillis(100), factory)) {
            assertThat(asString(generator.generate("html"))).isEqualTo("worker-1:html");
        } finally {
            logger.detachAppender(appender);
            appender.stop();
        }

        List<String> messages = appender.list.stream()
                .map(ILoggingEvent::getFormattedMessage)
                .toList();

        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_pool_initialized");
            assertThat(message).contains("poolSize=1");
            assertThat(message).contains("borrowTimeoutMs=100");
        });
        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_borrowed");
            assertThat(message).contains("borrowWaitMs=");
            assertThat(message).contains("availableWorkers=");
            assertThat(message).contains("activeWorkers=");
            assertThat(message).contains("managedWorkers=");
        });
        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_generation_completed");
            assertThat(message).contains("workerRenderMs=");
            assertThat(message).contains("pdfByteLength=");
        });
        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_returned");
            assertThat(message).contains("availableWorkers=");
        });
        assertThat(messages).anySatisfy(message -> assertThat(message).contains("exam_sprint_report_pdf_pool_closed"));
    }

    @Test
    void concurrentGenerateEntersAtMostPoolSizeWorkers() throws Exception {
        CountDownLatch firstBatchEntered = new CountDownLatch(2);
        CountDownLatch releaseWorkers = new CountDownLatch(1);
        AtomicInteger activeWorkers = new AtomicInteger();
        AtomicInteger maxActiveWorkers = new AtomicInteger();
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
            int active = activeWorkers.incrementAndGet();
            maxActiveWorkers.accumulateAndGet(active, Math::max);
            firstBatchEntered.countDown();
            await(releaseWorkers);
            activeWorkers.decrementAndGet();
            return bytes("worker-" + worker.id() + ":" + html);
        }));
        ExecutorService executor = Executors.newFixedThreadPool(4);

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(2, Duration.ofSeconds(2), factory)) {
            List<Future<byte[]>> futures = new ArrayList<>();
            for (int i = 0; i < 4; i++) {
                String html = "html-" + i;
                futures.add(executor.submit(() -> generator.generate(html)));
            }

            assertThat(firstBatchEntered.await(1, TimeUnit.SECONDS)).isTrue();
            assertThat(maxActiveWorkers.get()).isLessThanOrEqualTo(2);

            releaseWorkers.countDown();
            for (Future<byte[]> future : futures) {
                assertThat(asString(future.get(1, TimeUnit.SECONDS))).startsWith("worker-");
            }
            assertThat(maxActiveWorkers.get()).isLessThanOrEqualTo(2);
        } finally {
            executor.shutdownNow();
        }
    }

    @Test
    void generateTimesOutWhenNoWorkerIsAvailable() throws Exception {
        CountDownLatch workerEntered = new CountDownLatch(1);
        CountDownLatch releaseWorker = new CountDownLatch(1);
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
            workerEntered.countDown();
            await(releaseWorker);
            return bytes("done");
        }));
        ExecutorService executor = Executors.newSingleThreadExecutor();

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofMillis(50), factory)) {
            Future<byte[]> borrowed = executor.submit(() -> generator.generate("held"));
            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();

            assertThatThrownBy(() -> generator.generate("waiting"))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessageContaining("Timed out waiting for PDF worker");

            releaseWorker.countDown();
            assertThat(asString(borrowed.get(1, TimeUnit.SECONDS))).isEqualTo("done");
        } finally {
            releaseWorker.countDown();
            executor.shutdownNow();
        }
    }

    @Test
    void generateTimeoutEmitsStructuredPoolLog() throws Exception {
        CountDownLatch workerEntered = new CountDownLatch(1);
        CountDownLatch releaseWorker = new CountDownLatch(1);
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
            workerEntered.countDown();
            await(releaseWorker);
            return bytes("done");
        }));
        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        logger.addAppender(appender);
        ExecutorService executor = Executors.newSingleThreadExecutor();

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofMillis(50), factory)) {
            Future<byte[]> borrowed = executor.submit(() -> generator.generate("held"));
            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();

            assertThatThrownBy(() -> generator.generate("waiting"))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessageContaining("Timed out waiting for PDF worker");

            releaseWorker.countDown();
            assertThat(asString(borrowed.get(1, TimeUnit.SECONDS))).isEqualTo("done");
        } finally {
            releaseWorker.countDown();
            executor.shutdownNow();
            logger.detachAppender(appender);
            appender.stop();
        }

        List<String> messages = appender.list.stream()
                .map(ILoggingEvent::getFormattedMessage)
                .toList();

        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_borrow_timeout");
            assertThat(message).contains("poolSize=1");
            assertThat(message).contains("borrowTimeoutMs=50");
            assertThat(message).contains("borrowWaitMs=");
            assertThat(message).contains("exceptionType=IllegalStateException");
            assertThat(message).contains("availableWorkers=");
            assertThat(message).contains("activeWorkers=");
            assertThat(message).contains("managedWorkers=");
        });
    }

    @Test
    void generateFailureEmitsStructuredFailureAndReplacementLogs() {
        RuntimeException generateFailure = new RuntimeException("boom");
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> {
            if (worker.id() == 1) {
                worker.generateAction(html -> {
                    throw generateFailure;
                });
            }
        });
        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        logger.addAppender(appender);

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
            assertThatThrownBy(() -> generator.generate("failing"))
                    .isSameAs(generateFailure);
        } finally {
            logger.detachAppender(appender);
            appender.stop();
        }

        List<String> messages = appender.list.stream()
                .map(ILoggingEvent::getFormattedMessage)
                .toList();

        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_generation_failed");
            assertThat(message).contains("workerRenderMs=");
            assertThat(message).contains("exceptionType=RuntimeException");
            assertThat(message).contains("availableWorkers=");
            assertThat(message).contains("activeWorkers=");
            assertThat(message).contains("managedWorkers=");
        });
        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_replaced");
            assertThat(message).contains("poolSize=1");
            assertThat(message).contains("availableWorkers=");
            assertThat(message).contains("activeWorkers=");
            assertThat(message).contains("managedWorkers=");
        });
    }

    @Test
    void replacementFailureEmitsStructuredReplacementFailureLog() {
        RuntimeException generateFailure = new RuntimeException("boom");
        RuntimeException replacementFailure = new RuntimeException("replacement failed");
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
            throw generateFailure;
        }));
        factory.failOnCreateNumber(2, replacementFailure);
        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        logger.addAppender(appender);

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
            assertThatThrownBy(() -> generator.generate("failing"))
                    .isSameAs(generateFailure);
        } finally {
            logger.detachAppender(appender);
            appender.stop();
        }

        List<String> messages = appender.list.stream()
                .map(ILoggingEvent::getFormattedMessage)
                .toList();

        assertThat(messages).anySatisfy(message -> {
            assertThat(message).contains("exam_sprint_report_pdf_worker_replacement_failed");
            assertThat(message).contains("exceptionType=RuntimeException");
            assertThat(message).contains("availableWorkers=");
            assertThat(message).contains("activeWorkers=");
            assertThat(message).contains("managedWorkers=");
        });
    }

    @Test
    void generateFailureClosesFailedWorkerCreatesReplacementAndDoesNotRetryCurrentRequest() {
        RuntimeException generateFailure = new RuntimeException("boom");
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> {
            if (worker.id() == 1) {
                worker.generateAction(html -> {
                    throw generateFailure;
                });
            }
        });

        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
            assertThatThrownBy(() -> generator.generate("failing"))
                    .isSameAs(generateFailure);

            assertThat(factory.workers()).hasSize(2);
            assertThat(factory.worker(0).closeCount()).isEqualTo(1);
            assertThat(factory.worker(1).htmlContents()).isEmpty();

            assertThat(asString(generator.generate("after-failure"))).isEqualTo("worker-2:after-failure");
            assertThat(factory.worker(1).htmlContents()).containsExactly("after-failure");
        }
    }

    @Test
    void closeClosesIdleWorkersOnceAndRejectsGenerate() {
        FakeWorkerFactory factory = new FakeWorkerFactory();
        PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(2, factory);

        generator.close();
        generator.close();

        assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isEqualTo(1));
        assertThatThrownBy(() -> generator.generate("html"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("PDF generator pool is closed");
    }

    @Test
    void closeClosesActiveWorkerWhileGenerateIsRunning() throws Exception {
        CountDownLatch workerEntered = new CountDownLatch(1);
        CountDownLatch releaseWorker = new CountDownLatch(1);
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
            workerEntered.countDown();
            await(releaseWorker);
            return bytes("done");
        }));
        ExecutorService executor = Executors.newSingleThreadExecutor();

        try {
            PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofSeconds(5), factory);
            Future<byte[]> activeGenerate = executor.submit(() -> generator.generate("active"));
            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();

            generator.close();

            assertThat(factory.worker(0).closeCount()).isEqualTo(1);
            releaseWorker.countDown();
            assertThat(asString(activeGenerate.get(1, TimeUnit.SECONDS))).isEqualTo("done");
            assertThat(factory.worker(0).closeCount()).isEqualTo(1);
        } finally {
            releaseWorker.countDown();
            executor.shutdownNow();
        }
    }

    @Test
    void waitingBorrowFailsPromptlyWithPoolClosedWhenCloseIsCalled() throws Exception {
        CountDownLatch workerEntered = new CountDownLatch(1);
        CountDownLatch releaseWorker = new CountDownLatch(1);
        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
            workerEntered.countDown();
            await(releaseWorker);
            return bytes("done");
        }));
        ExecutorService executor = Executors.newFixedThreadPool(2);
        AtomicReference<Throwable> waitingFailure = new AtomicReference<>();
        Thread waitingThread = null;

        try {
            PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofSeconds(5), factory);
            Future<byte[]> activeGenerate = executor.submit(() -> generator.generate("active"));
            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();
            waitingThread = new Thread(() -> {
                try {
                    generator.generate("waiting");
                } catch (Throwable failure) {
                    waitingFailure.set(failure);
                }
            }, "pooled-pdf-waiting-borrow-test");
            waitingThread.start();
            awaitThreadState(waitingThread, Thread.State.TIMED_WAITING);

            generator.close();

            waitingThread.join(1_000);
            assertThat(waitingThread.isAlive()).isFalse();
            assertThat(waitingFailure.get())
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessageContaining("PDF generator pool is closed");
            releaseWorker.countDown();
            assertThat(asString(activeGenerate.get(1, TimeUnit.SECONDS))).isEqualTo("done");
        } finally {
            if (waitingThread != null) {
                waitingThread.interrupt();
            }
            releaseWorker.countDown();
            executor.shutdownNow();
        }
    }

    @Test
    void destroyClosesIdleWorkersOnceAndRejectsGenerate() {
        FakeWorkerFactory factory = new FakeWorkerFactory();
        PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(2, factory);

        generator.destroy();
        generator.destroy();

        assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isEqualTo(1));
        assertThatThrownBy(() -> generator.generate("html"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("PDF generator pool is closed");
    }

    @Test
    void constructorRejectsInvalidPoolSettings() {
        FakeWorkerFactory factory = new FakeWorkerFactory();

        assertThatThrownBy(() -> new PooledPlaywrightExamSprintReportPdfGenerator(0, Duration.ofMillis(1), factory))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("poolSize");
        assertThatThrownBy(() -> new PooledPlaywrightExamSprintReportPdfGenerator(1, Duration.ZERO, factory))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("borrowTimeout");
        assertThatThrownBy(() -> new PooledPlaywrightExamSprintReportPdfGenerator(1, Duration.ofMillis(-1), factory))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("borrowTimeout");
    }

    @Test
    void constructorClosesAlreadyCreatedWorkersWhenFactoryFails() {
        RuntimeException createFailure = new RuntimeException("create failed");
        FakeWorkerFactory factory = new FakeWorkerFactory();
        factory.failOnCreateNumber(3, createFailure);

        assertThatThrownBy(() -> newGenerator(3, factory))
                .isSameAs(createFailure);
        assertThat(factory.workers()).hasSize(2);
        assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isEqualTo(1));
    }

    private static PooledPlaywrightExamSprintReportPdfGenerator newGenerator(int poolSize,
                                                                            FakeWorkerFactory factory) {
        return newGenerator(poolSize, Duration.ofMillis(100), factory);
    }

    private static PooledPlaywrightExamSprintReportPdfGenerator newGenerator(int poolSize,
                                                                            Duration borrowTimeout,
                                                                            FakeWorkerFactory factory) {
        return new PooledPlaywrightExamSprintReportPdfGenerator(poolSize, borrowTimeout, factory);
    }

    private static String asString(byte[] bytes) {
        return new String(bytes, StandardCharsets.UTF_8);
    }

    private static byte[] bytes(String value) {
        return value.getBytes(StandardCharsets.UTF_8);
    }

    private static void await(CountDownLatch latch) {
        try {
            assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(exception);
        }
    }

    private static void awaitThreadState(Thread thread, Thread.State expectedState) throws InterruptedException {
        long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
        while (System.nanoTime() < deadlineNanos && thread.getState() != expectedState) {
            Thread.sleep(10);
        }
        assertThat(thread.getState()).isEqualTo(expectedState);
    }

    private static final class FakeWorkerFactory implements PlaywrightPdfWorkerFactory {

        private final List<FakeWorker> workers = Collections.synchronizedList(new ArrayList<>());
        private final Consumer<FakeWorker> customizer;
        private int failOnCreateNumber;
        private RuntimeException createFailure;

        private FakeWorkerFactory() {
            this(worker -> {
            });
        }

        private FakeWorkerFactory(Consumer<FakeWorker> customizer) {
            this.customizer = customizer;
        }

        @Override
        public synchronized PlaywrightPdfWorker create() {
            int nextId = workers.size() + 1;
            if (nextId == failOnCreateNumber) {
                throw createFailure;
            }
            FakeWorker worker = new FakeWorker(nextId);
            customizer.accept(worker);
            workers.add(worker);
            return worker;
        }

        private List<FakeWorker> workers() {
            return workers;
        }

        private FakeWorker worker(int index) {
            return workers.get(index);
        }

        private void failOnCreateNumber(int failOnCreateNumber, RuntimeException createFailure) {
            this.failOnCreateNumber = failOnCreateNumber;
            this.createFailure = createFailure;
        }
    }

    private static final class FakeWorker implements PlaywrightPdfWorker {

        private final int id;
        private final List<String> htmlContents = Collections.synchronizedList(new ArrayList<>());
        private final AtomicInteger closeCount = new AtomicInteger();
        private Function<String, byte[]> generateAction;

        private FakeWorker(int id) {
            this.id = id;
        }

        @Override
        public byte[] generate(String htmlContent) {
            htmlContents.add(htmlContent);
            if (generateAction != null) {
                return generateAction.apply(htmlContent);
            }
            return bytes("worker-" + id + ":" + htmlContent);
        }

        @Override
        public void close() {
            closeCount.incrementAndGet();
        }

        private int id() {
            return id;
        }

        private List<String> htmlContents() {
            return htmlContents;
        }

        private int closeCount() {
            return closeCount.get();
        }

        private void generateAction(Function<String, byte[]> generateAction) {
            this.generateAction = generateAction;
        }
    }
}
