/***** BEGIN LICENSE BLOCK *****
 * Version: EPL 2.0/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Eclipse Public
 * License Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of
 * the License at http://www.eclipse.org/legal/epl-v20.html
 *
 * Software distributed under the License is distributed on an "AS
 * IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
 * implied. See the License for the specific language governing
 * rights and limitations under the License.
 *
 * Copyright (C) 2026 Charles O Nutter <headius@headius.com>
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either of the GNU General Public License Version 2 or later (the "GPL"),
 * or the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the EPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the EPL, the GPL or the LGPL.
 ***** END LICENSE BLOCK *****/
package org.jruby.embed;

import org.jruby.Ruby;
import org.junit.Test;

import java.lang.management.ManagementFactory;
import java.lang.ref.WeakReference;
import java.util.concurrent.atomic.AtomicReference;

import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

/**
 * Regression test for https://github.com/jruby/jruby/issues/9092
 * <p/>
 * The basic tests here are:
 *
 * <ul>
 *     <li>Main thread creates and terminates a ScriptingContainer and verifies the runtime is collected.</li>
 *     <li>Child thread does the same.</li>
 *     <li>Main thread creates and terminates down several containers and verifies minimmal loaded classes growth.</li>
 * </ul>
 *
 * This test was generated by Copilot and justified with incorrect logic, but the basic tests
 * are useful. This version was modified and cleaned up by headius.
 */
public class ScriptingContainerClassLeakTest {

    /**
     * Primary regression test for GH-9092.
     *
     * After the container is terminated (engine disposed), the Ruby runtime must
     * be eligible for collection by a normal GC — not just when the JVM is under
     * memory pressure.
     *
     * Without the fix: the calling thread's ThreadLocalMap retains
     *   Entry.value = SoftRef&lt;ThreadContext&gt; → ThreadContext → Ruby
     * so Ruby remains <em>softly reachable</em> and survives System.gc().
     *
     * With the fix: teardown() calls SoftReference.clear() on every tracked ref,
     * breaking the chain; Ruby becomes only weakly reachable and is collected.
     */
    @Test
    public void runtimeIsGCdAfterTeardown() throws InterruptedException {
        // Create engine and dispose inside a helper method so that the
        // ScriptingContainer is guaranteed out-of-scope when we check GC.
        WeakReference<Ruby> ref = createEvalAndClose();
        forceGC();
        assertNull(
                "Ruby runtime was not collected after terminate() (GH-9092). " +
                        "The calling thread's ThreadLocalMap is holding a SoftReference to " +
                        "the runtime's ThreadContext, keeping it alive across GC cycles.",
                ref.get()
        );
    }

    /**
     * Secondary regression test, perform the same action in a non-main thread. Child thread still handles runtime
     * tearDown.
     *
     * @throws InterruptedException
     */
    @Test
    public void runtimeIsGCdAfterWorkerThreadTeardown() throws InterruptedException {
        AtomicReference<WeakReference<Ruby>> refHolder = new AtomicReference<>();

        Thread thread = new Thread(() -> refHolder.set(createEvalAndClose()));

        thread.start();
        thread.join();

        WeakReference<Ruby> ref = refHolder.get();
        forceGC();

        assertNull(
                "Ruby runtime created and terminated on a worker thread was not collected after terminate() (GH-9092).",
                ref.get()
        );
    }

    /**
     * Create a container, eval something (so the calling thread's ThreadContext
     * is registered via ThreadService.set()), then dispose it.  Returning only a
     * WeakReference ensures the container itself is out-of-scope at the call site.
     */
    private static WeakReference<Ruby> createEvalAndClose() {
        ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
        // trigger runtime and thread initialization
        sc.runScriptlet("nil");

        var ref = new WeakReference<>(sc.getProvider().getRuntime());

        // terminate container and ensure global runtime is also cleared
        sc.terminate();
        Ruby.clearGlobalRuntime();

        return ref;
    }

    /**
     * Check count of loaded classes after loading and terminating several runtimes.
     *
     * Generated by Copilot, modified by headius.
     */
    @Test
    public void classCountDoesNotGrowUnboundedly() throws InterruptedException {
        // Ensure all classes are loaded before we start counting.
        for (int i = 0; i < 3; i++) createAndTearDown();
        forceGC();

        long classesBefore = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount();

        for (int i = 0; i < 20; i++) {
            createAndTearDown();
            forceGC();
        }

        long classesAfter = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount();
        long growth = classesAfter - classesBefore;

        // Generous constant budget (lazy JDK internals etc.), but not per-cycle growth.
        // Pre-fix behaviour was ~98 new classes per cycle * 10 = ~980.
        assertTrue(
                "Loaded class count grew by " + growth + " across 10 ScriptingContainer " +
                        "cycles (max allowed: 200). Classloader leak detected (see GH-9092).",
                growth <= 200
        );
    }

    /**
     * Create runtime, run some code, and tear down immediately without retaining any references.
     */
    private static void createAndTearDown() {
        ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
        try {
            sc.runScriptlet("1 + 1");
        } finally {
            sc.terminate();
            Ruby.clearGlobalRuntime();
        }
    }


    @SuppressWarnings({"deprecation", "removal"})
    private static void forceGC() throws InterruptedException {
        // ScriptingContainer has finalize(), so two GC passes are needed:
        // 1st: detects sc is unreachable → enqueues for finalization
        // System.runFinalization(): runs sc.finalize() → sc now truly collectable
        // 2nd: collects sc + LocalContext → Ruby loses its strong-ref path
        // 3rd+: Ruby (with fix: weakly reachable) is collected
        for (int i = 0; i < 5; i++) {
            System.gc();
            System.runFinalization();
            Thread.sleep(100);
        }
    }
}