/*
 * Canvas3DManager.java 25 oct. 07
 *
 * Sweet Home 3D, Copyright (c) 2024 Space Mushrooms <info@sweethome3d.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.j3d;

import java.awt.Graphics;
import java.awt.GraphicsConfigTemplate;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Window;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.HierarchyEvent;
import java.awt.event.HierarchyListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.event.WindowListener;
import java.awt.image.BufferedImage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import javax.media.j3d.Canvas3D;
import javax.media.j3d.GraphicsConfigTemplate3D;
import javax.media.j3d.IllegalRenderingStateException;
import javax.media.j3d.ImageComponent2D;
import javax.media.j3d.RenderingError;
import javax.media.j3d.RenderingErrorListener;
import javax.media.j3d.Screen3D;
import javax.media.j3d.View;
import javax.media.j3d.VirtualUniverse;
import javax.swing.SwingUtilities;
import javax.swing.Timer;

import com.eteks.sweethome3d.tools.OperatingSystem;
import com.sun.j3d.utils.universe.SimpleUniverse;
import com.sun.j3d.utils.universe.Viewer;
import com.sun.j3d.utils.universe.ViewingPlatform;

/**
 * Manager of <code>Canvas3D</code> instantiations and Java 3D error listeners.
 * Note: this class is compatible with Java 3D 1.3 at runtime but requires Java 3D 1.5 to compile.
 * @author Emmanuel Puybaret
 */
public class Component3DManager {
  private static final String CHECK_OFF_SCREEN_IMAGE_SUPPORT = "com.eteks.sweethome3d.j3d.checkOffScreenSupport";

  private static Component3DManager instance;

  private RenderingErrorObserver renderingErrorObserver;
  // The Java 3D listener matching renderingErrorObserver
  // (use Object class to ensure Component3DManager class can run with Java 3D 1.3.1)
  private Object                 renderingErrorListener;
  private Boolean                offScreenImageSupported;
  private GraphicsConfiguration  defaultScreenConfiguration;
  private int                    depthSize;

  private Component3DManager() {
    if (!GraphicsEnvironment.isHeadless()) {
      // Request depth size equal to 24 if supported
      int preferredDepthSize = 24;
      try {
        preferredDepthSize = Integer.valueOf(System.getProperty("com.eteks.sweethome3d.j3d.depthSize", "24"));
      } catch (NumberFormatException ex) {
        // Keep 24
      }
      GraphicsConfigTemplate3D template = createGraphicsConfigurationTemplate3D(preferredDepthSize);
      this.defaultScreenConfiguration = createScreenConfiguration(template);
      if (OperatingSystem.isWindows()) {
        if (!isOffScreenImageSupported()) {
          // Try again with default depth size
          template = createGraphicsConfigurationTemplate3D(new GraphicsConfigTemplate3D().getDepthSize());
          this.defaultScreenConfiguration = createScreenConfiguration(template);
          // Reset offscreen support flag
          this.offScreenImageSupported = null;
        }
      }
      this.depthSize = template.getDepthSize();
    } else {
      this.offScreenImageSupported = Boolean.FALSE;
    }
  }

  /**
   * Returns the template to configure the graphics of canvas 3D.
   */
  private GraphicsConfigTemplate3D createGraphicsConfigurationTemplate3D(int preferredDepthSize) {
    if (System.getProperty("j3d.implicitAntialiasing") == null) {
      System.setProperty("j3d.implicitAntialiasing", "true");
    }

    GraphicsConfigTemplate3D template = new GraphicsConfigTemplate3D();
    int defaultDepthSize = template.getDepthSize();
    if (defaultDepthSize != preferredDepthSize) {
      template.setDepthSize(preferredDepthSize);
      if (!template.isGraphicsConfigSupported(
          GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration())) {
        template.setDepthSize(defaultDepthSize);
      }
    }

    // Try to get antialiasing
    template.setSceneAntialiasing(GraphicsConfigTemplate3D.PREFERRED);

    // From http://www.java.net/node/683852
    // Check if the user has set the Java 3D stereo option.
    String stereo = System.getProperty("j3d.stereo");
    if (stereo != null) {
      if ("REQUIRED".equals(stereo)) {
        template.setStereo(GraphicsConfigTemplate.REQUIRED);
      } else if ("PREFERRED".equals(stereo)) {
        template.setStereo(GraphicsConfigTemplate.PREFERRED);
      }
    }

    return template;
  }

  /**
   * Returns a screen configuration from the preferred template.
   */
  private GraphicsConfiguration createScreenConfiguration(GraphicsConfigTemplate template) {
    GraphicsDevice defaultScreenDevice = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
    GraphicsConfiguration screenConfiguration = defaultScreenDevice.getBestConfiguration(template);
    if (screenConfiguration == null) {
      template = new GraphicsConfigTemplate3D();
      screenConfiguration = defaultScreenDevice.getBestConfiguration(template);
    }
    return screenConfiguration;
  }

  /**
   * Returns the depth bits size of the Z-buffer.
   */
  public int getDepthSize() {
    return this.depthSize;
  }

  /**
   * Returns an instance of this singleton.
   */
  public static Component3DManager getInstance() {
    if (instance == null) {
      instance = new Component3DManager();
    }
    return instance;
  }

  /**
   * Sets the current rendering error listener bound to <code>VirtualUniverse</code>.
   */
  public void setRenderingErrorObserver(RenderingErrorObserver observer) {
    try {
      Class.forName("javax.media.j3d.RenderingErrorListener");
      this.renderingErrorListener = RenderingErrorListenerManager.setRenderingErrorObserver(
          observer, this.renderingErrorListener);
      this.renderingErrorObserver = observer;
    } catch (ClassNotFoundException ex) {
      // As RenderingErrorListener and addRenderingErrorListener are available since Java 3D 1.5,
      // use the default rendering error reporting if Sweet Home 3D is linked to a previous version
    }
  }

  /**
   * Returns the current rendering error listener bound to <code>VirtualUniverse</code>.
   */
  public RenderingErrorObserver getRenderingErrorObserver() {
    return this.renderingErrorObserver;
  }

  /**
   * Returns <code>true</code> if offscreen is supported in Java 3D on user system.
   * Will always return <code>false</code> if <code>com.eteks.sweethome3d.j3d.checkOffScreenSupport</code>
   * system is equal to <code>false</code>. By default, <code>com.eteks.sweethome3d.j3d.checkOffScreenSupport</code>
   * is equal to <code>true</code>.
   */
  public boolean isOffScreenImageSupported() {
    if (this.offScreenImageSupported == null) {
      if ("false".equalsIgnoreCase(System.getProperty(CHECK_OFF_SCREEN_IMAGE_SUPPORT, "true"))) {
        this.offScreenImageSupported = Boolean.FALSE;
      } else if (OperatingSystem.isMacOSX()) {
        // Avoid testing under macOS where it can lead to deadlocks during getOffScreenImage call with JOGL 2.4
        this.offScreenImageSupported = Boolean.TRUE;
      } else {
        SimpleUniverse universe = null;
        try {
          // Create a universe bound to no canvas 3D
          ViewingPlatform viewingPlatform = new ViewingPlatform();
          Viewer viewer = new Viewer(new Canvas3D [0]);
          universe = new SimpleUniverse(viewingPlatform, viewer);
          // Create a dummy 3D image to check if it can be rendered in current Java 3D configuration
          getOffScreenImage(viewer.getView(), 1, 1);
          this.offScreenImageSupported = Boolean.TRUE;
        } catch (IllegalRenderingStateException ex) {
          this.offScreenImageSupported = Boolean.FALSE;
        } catch (NullPointerException ex) {
          this.offScreenImageSupported = Boolean.FALSE;
        } catch (IllegalArgumentException ex) {
          this.offScreenImageSupported = Boolean.FALSE;
        } finally {
          if (universe != null) {
            universe.cleanup();
          }
        }
      }
    }
    return this.offScreenImageSupported;
  }

  /**
   * Returns a new <code>canva3D</code> instance that will call <code>renderingObserver</code>
   * methods during the rendering loop.
   * @throws IllegalRenderingStateException  if the canvas 3D couldn't be created.
   */
  private Canvas3D getCanvas3D(GraphicsConfiguration deviceConfiguration,
                               boolean offscreen,
                               final RenderingObserver renderingObserver) {
    GraphicsConfiguration configuration;
    if (GraphicsEnvironment.isHeadless()) {
      configuration = null;
    } else if (deviceConfiguration == null
               || deviceConfiguration.getDevice() == this.defaultScreenConfiguration.getDevice()) {
      configuration = this.defaultScreenConfiguration;
    } else {
      GraphicsConfigTemplate3D template = createGraphicsConfigurationTemplate3D(this.depthSize);
      configuration = deviceConfiguration.getDevice().getBestConfiguration(template);
      if (configuration == null) {
        configuration = deviceConfiguration.getDevice().getBestConfiguration(new GraphicsConfigTemplate3D());
      }
    }
    if (configuration == null) {
      throw new IllegalRenderingStateException("Can't create graphics environment for Canvas 3D");
    }
    try {
      // Ensure unused canvases are freed
      System.gc();

      // Create a Java 3D canvas
      final Canvas3D canvas3D;
      if (renderingObserver != null) {
        canvas3D = new ObservedCanvas3D(configuration, offscreen, renderingObserver);
      } else {
        canvas3D = new Canvas3D(configuration, offscreen);
      }

      if (!offscreen
          && OperatingSystem.isLinux()
          && OperatingSystem.isJavaVersionGreaterOrEqual("1.7")) {
        // Add a listener to the parent window once known that will repaint the canvas in 100 ms
        // when the window is activated / deactivated to avoid keeping an empty canvas
        final WindowListener parentActivationListener = new WindowAdapter() {
            private Timer timer;

            @Override
            public void windowActivated(WindowEvent ev) {
              if (this.timer == null) {
                this.timer = new Timer(100, new ActionListener() {
                    public void actionPerformed(ActionEvent ev) {
                      canvas3D.repaint();
                    }
                  });
                this.timer.setRepeats(false);
              }
              this.timer.restart();
            }

            @Override
            public void windowDeactivated(WindowEvent ev) {
              windowActivated(null);
            }
          };
        canvas3D.addHierarchyListener(new HierarchyListener() {
            private Window parentWindow;

            public void hierarchyChanged(HierarchyEvent ev) {
              Window window = SwingUtilities.windowForComponent(canvas3D);
              if (window != null) {
                if (this.parentWindow != window) {
                  window.addWindowListener(parentActivationListener);
                }
              } else if (this.parentWindow != null) {
                this.parentWindow.removeWindowListener(parentActivationListener);
              }
              this.parentWindow = window;
            }
          });
      }

      return canvas3D;
    } catch (IllegalArgumentException ex) {
      IllegalRenderingStateException ex2 = new IllegalRenderingStateException("Can't create Canvas 3D");
      ex2.initCause(ex);
      throw ex2;
    }
  }

  /**
   * Returns a new on screen <code>canva3D</code> instance. The returned canvas 3D will be associated
   * with the graphics configuration of the default screen device.
   * @throws IllegalRenderingStateException  if the canvas 3D couldn't be created.
   */
  public Canvas3D getOnscreenCanvas3D() {
    return getOnscreenCanvas3D(null);
  }

  /**
   * Returns a new on screen <code>canva3D</code> instance which rendering will be observed
   * with the given rendering observer. The returned canvas 3D will be associated with the
   * graphics configuration of the default screen device.
   * @param renderingObserver an observer of the 3D rendering process of the returned canvas.
   *            Caution: The methods of the observer will be called in 3D rendering loop thread.
   * @throws IllegalRenderingStateException  if the canvas 3D couldn't be created.
   */
  public Canvas3D getOnscreenCanvas3D(RenderingObserver renderingObserver) {
    return getCanvas3D(null, false, renderingObserver);
  }

  /**
   * Returns a new on screen <code>canva3D</code> instance which rendering will be observed
   * with the given rendering observer.
   * @param renderingObserver an observer of the 3D rendering process of the returned canvas.
   *            Caution: The methods of the observer will be called in 3D rendering loop thread.
   * @throws IllegalRenderingStateException  if the canvas 3D couldn't be created.
   */
  public Canvas3D getOnscreenCanvas3D(GraphicsConfiguration deviceConfiguration,
                                      RenderingObserver renderingObserver) {
    return getCanvas3D(deviceConfiguration, false, renderingObserver);
  }

  /**
   * Returns a new off screen <code>canva3D</code> at the given size.
   * @throws IllegalRenderingStateException  if the canvas 3D couldn't be created.
   *    To avoid this exception, call {@link #isOffScreenImageSupported() isOffScreenImageSupported()} first.
   */
  public Canvas3D getOffScreenCanvas3D(int width, int height) {
    Canvas3D offScreenCanvas = getCanvas3D(null, true, null);
    // Configure canvas 3D for offscreen
    Screen3D screen3D = offScreenCanvas.getScreen3D();
    screen3D.setSize(width, height);
    screen3D.setPhysicalScreenWidth(2f);
    screen3D.setPhysicalScreenHeight(2f / width * height);
    BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
    ImageComponent2D imageComponent2D = new ImageComponent2D(ImageComponent2D.FORMAT_RGB, image);
    imageComponent2D.setCapability(ImageComponent2D.ALLOW_IMAGE_READ);
    offScreenCanvas.setOffScreenBuffer(imageComponent2D);
    return offScreenCanvas;
  }

  /**
   * Returns an image at the given size of the 3D <code>view</code>.
   * This image is created with an off screen canvas.
   * @throws IllegalRenderingStateException  if the image couldn't be created.
   */
  public BufferedImage getOffScreenImage(View view, int width, int height)  {
    Canvas3D offScreenCanvas = null;
    RenderingErrorObserver previousRenderingErrorObserver = getRenderingErrorObserver();
    try {
      // Replace current rendering error observer by a listener that counts down
      // a latch to check further if a rendering error happened during off screen rendering
      // (rendering error listener is called from a notification thread)
      final CountDownLatch latch = new CountDownLatch(1);
      setRenderingErrorObserver(new RenderingErrorObserver() {
          public void errorOccured(int errorCode, String errorMessage) {
            latch.countDown();
          }
        });

      // Create an off screen canvas and bind it to view
      offScreenCanvas = getOffScreenCanvas3D(width, height);
      view.addCanvas3D(offScreenCanvas);

      // Render off screen canvas
      offScreenCanvas.renderOffScreenBuffer();
      offScreenCanvas.waitForOffScreenRendering();

      // If latch count becomes equal to 0 during the past instructions or in the coming 10 milliseconds,
      // this means that a rendering error happened (for example, in case changing default depth size isn't supported)
      if (latch.await(10, TimeUnit.MILLISECONDS)) {
        throw new IllegalRenderingStateException("Off screen rendering unavailable");
      }

      return offScreenCanvas.getOffScreenBuffer().getImage();
    } catch (InterruptedException ex) {
      IllegalRenderingStateException ex2 =
          new IllegalRenderingStateException("Off screen rendering interrupted");
      ex2.initCause(ex);
      throw ex2;
    } finally {
      if (offScreenCanvas != null) {
        view.removeCanvas3D(offScreenCanvas);
        try {
          // Free off screen buffer and context
          offScreenCanvas.setOffScreenBuffer(null);
        } catch (NullPointerException ex) {
          // Java 3D 1.3 may throw an exception
        }
      }
      // Reset previous rendering error listener
      setRenderingErrorObserver(previousRenderingErrorObserver);
    }
  }

  /**
   * An observer that receives error notifications in Java 3D.
   */
  public static interface RenderingErrorObserver {
    void errorOccured(int errorCode, String errorMessage);
  }

  /**
   * Manages Java 3D 1.5 <code>RenderingErrorListener</code> change matching the given
   * rendering error observer.
   */
  private static class RenderingErrorListenerManager {
    public static Object setRenderingErrorObserver(final RenderingErrorObserver observer,
                                                   Object previousRenderingErrorListener) {
      if (previousRenderingErrorListener != null) {
        VirtualUniverse.removeRenderingErrorListener(
            (RenderingErrorListener)previousRenderingErrorListener);
      }
      RenderingErrorListener renderingErrorListener = new RenderingErrorListener() {
        public void errorOccurred(RenderingError error) {
          observer.errorOccured(error.getErrorCode(), error.getErrorMessage());
        }
      };
      VirtualUniverse.addRenderingErrorListener(renderingErrorListener);
      return renderingErrorListener;
    }
  }

  /**
   * An observer that receives notifications during the different steps
   * of the loop rendering a canvas 3D.
   */
  public static interface RenderingObserver {
    /**
     * Called before <code>canvas3D</code> is rendered.
     */
    public void canvas3DPreRendered(Canvas3D canvas3D);

    /**
     * Called after <code>canvas3D</code> is rendered.
     */
    public void canvas3DPostRendered(Canvas3D canvas3D);

    /**
     * Called after <code>canvas3D</code> buffer is swapped.
     */
    public void canvas3DSwapped(Canvas3D canvas3D);
  }

  /**
   * A canvas 3D observed during its rendering.
   */
  private static class ObservedCanvas3D extends Canvas3D {
    private final RenderingObserver renderingObserver;
    private final boolean           paintDelayed;
    private Timer timer;

    private ObservedCanvas3D(GraphicsConfiguration graphicsConfiguration,
                             boolean offScreen,
                             RenderingObserver renderingObserver) {
      super(graphicsConfiguration, offScreen);
      this.renderingObserver = renderingObserver;
      // Under Windows with Java 7 and above, delay the rendering of the canvas 3D when
      // it's repainted (i.e. it's resized, moved or partially hidden) to avoid it to get grayed
      this.paintDelayed = OperatingSystem.isWindows()
          && OperatingSystem.isJavaVersionGreaterOrEqual("1.7");
    }

    @Override
    public void preRender() {
      this.renderingObserver.canvas3DPreRendered(this);
    }

    @Override
    public void postRender() {
      this.renderingObserver.canvas3DPostRendered(this);
    }

    @Override
    public void postSwap() {
      this.renderingObserver.canvas3DSwapped(this);
    }

    @Override
    public void paint(Graphics g) {
      if (this.paintDelayed) {
        if (this.timer == null) {
          this.timer = new Timer(100, new ActionListener() {
              public void actionPerformed(ActionEvent ev) {
                Graphics g = getGraphics();
                if (g != null) {
                  ObservedCanvas3D.super.paint(g);
                  g.dispose();
                }
              }
            });
          this.timer.setRepeats(false);
        }
        this.timer.restart();
      } else {
        super.paint(g);
      }
    }
  }
}
