package com.github.sarxos.webcam.ds.buildin;

import java.awt.Dimension;
import java.awt.Transparency;
import java.awt.color.ColorSpace;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.ComponentColorModel;
import java.awt.image.ComponentSampleModel;
import java.awt.image.DataBuffer;
import java.awt.image.DataBufferByte;
import java.awt.image.Raster;
import java.awt.image.WritableRaster;
import java.nio.ByteBuffer;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.bridj.Pointer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.github.sarxos.webcam.WebcamDevice;
import com.github.sarxos.webcam.WebcamDevice.BufferAccess;
import com.github.sarxos.webcam.WebcamException;
import com.github.sarxos.webcam.WebcamExceptionHandler;
import com.github.sarxos.webcam.WebcamResolution;
import com.github.sarxos.webcam.WebcamTask;
import com.github.sarxos.webcam.ds.buildin.natives.Device;
import com.github.sarxos.webcam.ds.buildin.natives.DeviceList;
import com.github.sarxos.webcam.ds.buildin.natives.OpenIMAJGrabber;

public class WebcamDefaultDevice implements WebcamDevice, BufferAccess, Runnable, WebcamDevice.FPSSource
{

	/**
	 * Logger.
	 */
	private static final Logger LOG = LoggerFactory.getLogger(WebcamDefaultDevice.class);

	/**
	 * The device memory buffer size.
	 */
	private static final int DEVICE_BUFFER_SIZE = 5;

	/** is it faulty? */
	public boolean FAULTY = false;

	/**
	 * Artificial view sizes. I'm really not sure if will fit into other webcams but hope that
	 * OpenIMAJ can handle this.
	 */
	private final static Dimension[] DIMENSIONS = new Dimension[] { WebcamResolution.QQVGA.getSize(), WebcamResolution.QVGA.getSize(), WebcamResolution.VGA.getSize(), };

	private class NextFrameTask extends WebcamTask
	{

		private final AtomicInteger result = new AtomicInteger(0);

		public NextFrameTask(WebcamDevice device)
		{
			super(device);
		}

		public int nextFrame()
		{
			try
			{
				this.process();
			}
			catch (InterruptedException e)
			{
				LOG.debug("Image buffer request interrupted", e);
			}
			return this.result.get();
		}

		@Override
		protected void handle()
		{

			WebcamDefaultDevice device = (WebcamDefaultDevice) this.getDevice();
			if (!device.isOpen())
			{
				return;
			}

			this.result.set(WebcamDefaultDevice.this.grabber.nextFrame());
			WebcamDefaultDevice.this.fresh.set(true);
		}
	}

	/**
	 * RGB offsets.
	 */
	private static final int[] BAND_OFFSETS = new int[] { 0, 1, 2 };

	/**
	 * Number of bytes in each pixel.
	 */
	private static final int[] BITS = { 8, 8, 8 };

	/**
	 * Image offset.
	 */
	private static final int[] OFFSET = new int[] { 0 };

	/**
	 * Data type used in image.
	 */
	private static final int DATA_TYPE = DataBuffer.TYPE_BYTE;

	/**
	 * Image color space.
	 */
	private static final ColorSpace COLOR_SPACE = ColorSpace.getInstance(ColorSpace.CS_sRGB);

	/**
	 * Maximum image acquisition time (in milliseconds).
	 */
	private int timeout = 5000;

	private OpenIMAJGrabber grabber = null;
	private Device device = null;
	private Dimension size = null;
	private ComponentSampleModel smodel = null;
	private ColorModel cmodel = null;
	private boolean failOnSizeMismatch = false;

	private final AtomicBoolean disposed = new AtomicBoolean(false);
	private final AtomicBoolean open = new AtomicBoolean(false);

	/**
	 * Is the last image fresh one.
	 */
	private final AtomicBoolean fresh = new AtomicBoolean(false);

	private Thread refresher = null;

	private String name = null;
	private String id = null;
	private String fullname = null;

	private long t1 = -1;
	private long t2 = -1;

	/**
	 * Current FPS.
	 */
	private volatile double fps = 0;

	protected WebcamDefaultDevice(Device device)
	{
		this.device = device;
		this.name = device.getNameStr();
		this.id = device.getIdentifierStr();
		this.fullname = String.format("%s %s", this.name, this.id);
	}

	@Override
	public String getName()
	{
		return this.fullname;
	}

	public String getDeviceName()
	{
		return this.name;
	}

	public String getDeviceId()
	{
		return this.id;
	}

	public Device getDeviceRef()
	{
		return this.device;
	}

	@Override
	public Dimension[] getResolutions()
	{
		return DIMENSIONS;
	}

	@Override
	public Dimension getResolution()
	{
		if (this.size == null)
		{
			this.size = this.getResolutions()[0];
		}
		return this.size;
	}

	@Override
	public void setResolution(Dimension size)
	{

		if (size == null)
		{
			throw new IllegalArgumentException("Size cannot be null");
		}

		if (this.open.get())
		{
			throw new IllegalStateException("Cannot change resolution when webcam is open, please close it first");
		}

		this.size = size;
	}

	@Override
	public ByteBuffer getImageBytes()
	{

		if (this.disposed.get())
		{
			LOG.debug("Webcam is disposed, image will be null");
			return null;
		}
		if (!this.open.get())
		{
			LOG.debug("Webcam is closed, image will be null");
			return null;
		}

		// if image is not fresh, update it

		if (this.fresh.compareAndSet(false, true))
		{
			this.updateFrameBuffer();
		}

		// get image buffer

		LOG.trace("Webcam grabber get image pointer");

		Pointer<Byte> image = this.grabber.getImage();
		this.fresh.set(false);

		if (image == null)
		{
			LOG.warn("Null array pointer found instead of image");
			return null;
		}

		int length = this.size.width * this.size.height * 3;

		LOG.trace("Webcam device get buffer, read {} bytes", length);

		return image.getByteBuffer(length);
	}

	@Override
	public void getImageBytes(ByteBuffer target)
	{

		if (this.disposed.get())
		{
			LOG.debug("Webcam is disposed, image will be null");
			return;
		}
		if (!this.open.get())
		{
			LOG.debug("Webcam is closed, image will be null");
			return;
		}

		int minSize = this.size.width * this.size.height * 3;
		int curSize = target.remaining();

		if (minSize < curSize)
		{
			throw new IllegalArgumentException(String.format("Not enough remaining space in target buffer (%d necessary vs %d remaining)", minSize, curSize));
		}

		// if image is not fresh, update it

		if (this.fresh.compareAndSet(false, true))
		{
			this.updateFrameBuffer();
		}

		// get image buffer

		LOG.trace("Webcam grabber get image pointer");

		Pointer<Byte> image = this.grabber.getImage();
		this.fresh.set(false);

		if (image == null)
		{
			LOG.warn("Null array pointer found instead of image");
			return;
		}

		LOG.trace("Webcam device read buffer {} bytes", minSize);

		image = image.validBytes(minSize);
		image.getBytes(target);

	}

	@Override
	public BufferedImage getImage()
	{

		ByteBuffer buffer = this.getImageBytes();

		if (buffer == null)
		{
			LOG.error("Images bytes buffer is null!");
			return null;
		}

		byte[] bytes = new byte[this.size.width * this.size.height * 3];
		byte[][] data = new byte[][] { bytes };

		buffer.get(bytes);

		DataBufferByte dbuf = new DataBufferByte(data, bytes.length, OFFSET);
		WritableRaster raster = Raster.createWritableRaster(this.smodel, dbuf, null);

		BufferedImage bi = new BufferedImage(this.cmodel, raster, false, null);
		bi.flush();

		return bi;
	}

	@Override
	public void open()
	{

		if (this.disposed.get())
		{
			return;
		}

		LOG.debug("Opening webcam device {}", this.getName());

		if (this.size == null)
		{
			this.size = this.getResolutions()[0];
		}
		if (this.size == null)
		{
			throw new RuntimeException("The resolution size cannot be null");
		}

		LOG.debug("Webcam device {} starting session, size {}", this.device.getIdentifierStr(), this.size);

		this.grabber = new OpenIMAJGrabber();

		// NOTE!

		// Following the note from OpenIMAJ code - it seams like there is some
		// issue on 32-bit systems which prevents grabber to find devices.
		// According to the mentioned note this for loop shall fix the problem.

		DeviceList list = this.grabber.getVideoDevices().get();
		for (Device d : list.asArrayList())
		{
			d.getNameStr();
			d.getIdentifierStr();
		}

		boolean started = this.grabber.startSession(this.size.width, this.size.height, 50, Pointer.pointerTo(this.device));
		if (!started)
		{
			throw new WebcamException("Cannot start native grabber!");
		}

		// set timeout, this MUST be done after grabber is open and before it's closed, otherwise it
		// will result as crash

		this.grabber.setTimeout(this.timeout);

		LOG.debug("Webcam device session started");

		Dimension size2 = new Dimension(this.grabber.getWidth(), this.grabber.getHeight());

		int w1 = this.size.width;
		int w2 = size2.width;
		int h1 = this.size.height;
		int h2 = size2.height;

		if (w1 != w2 || h1 != h2)
		{

			if (this.failOnSizeMismatch)
			{
				throw new WebcamException(String.format("Different size obtained vs requested - [%dx%d] vs [%dx%d]", w1, h1, w2, h2));
			}

			Object[] args = new Object[] { w1, h1, w2, h2, w2, h2 };
			LOG.warn("Different size obtained vs requested - [{}x{}] vs [{}x{}]. Setting correct one. New size is [{}x{}]", args);

			this.size = new Dimension(w2, h2);
		}

		this.smodel = new ComponentSampleModel(DATA_TYPE, this.size.width, this.size.height, 3, this.size.width * 3, BAND_OFFSETS);
		this.cmodel = new ComponentColorModel(COLOR_SPACE, BITS, false, false, Transparency.OPAQUE, DATA_TYPE);

		// clear device memory buffer

		LOG.debug("Clear memory buffer");

		this.clearMemoryBuffer();

		// set device to open

		LOG.debug("Webcam device {} is now open", this);

		this.open.set(true);

		// start underlying frames refresher

		this.refresher = this.startFramesRefresher();
	}

	/**
	 * this is to clean up all frames from device memory buffer which causes initial frames to be
	 * completely blank (black images)
	 */
	private void clearMemoryBuffer()
	{
		for (int i = 0; i < DEVICE_BUFFER_SIZE; i++)
		{
			this.grabber.nextFrame();
		}
	}

	/**
	 * Start underlying frames refresher.
	 * 
	 * @return Refresher thread
	 */
	private Thread startFramesRefresher()
	{
		Thread refresher = new Thread(this, String.format("frames-refresher-[%s]", this.id));
		refresher.setUncaughtExceptionHandler(WebcamExceptionHandler.getInstance());
		refresher.setDaemon(true);
		refresher.start();
		return refresher;
	}

	@Override
	public void close()
	{

		if (!this.open.compareAndSet(true, false))
		{
			return;
		}

		LOG.debug("Closing webcam device");

		this.grabber.stopSession();
	}

	@Override
	public void dispose()
	{

		if (!this.disposed.compareAndSet(false, true))
		{
			return;
		}

		LOG.debug("Disposing webcam device {}", this.getName());

		this.close();
	}

	/**
	 * Determines if device should fail when requested image size is different than actually
	 * received.
	 * 
	 * @param fail the fail on size mismatch flag, true or false
	 */
	public void setFailOnSizeMismatch(boolean fail)
	{
		this.failOnSizeMismatch = fail;
	}

	@Override
	public boolean isOpen()
	{
		return this.open.get();
	}

	/**
	 * Get timeout for image acquisition.
	 * 
	 * @return Value in milliseconds
	 */
	public int getTimeout()
	{
		return this.timeout;
	}

	/**
	 * Set timeout for image acquisition.
	 * 
	 * @param timeout the timeout value in milliseconds
	 */
	public void setTimeout(int timeout)
	{
		if (this.isOpen())
		{
			throw new WebcamException("Timeout must be set before webcam is open");
		}
		this.timeout = timeout;
	}

	/**
	 * Update underlying memory buffer and fetch new frame.
	 */
	private boolean updateFrameBuffer()
	{

		LOG.trace("Next frame");

		if (this.t1 == -1 || this.t2 == -1)
		{
			this.t1 = System.currentTimeMillis();
			this.t2 = System.currentTimeMillis();
		}

		int result = new NextFrameTask(this).nextFrame();

		this.t1 = this.t2;
		this.t2 = System.currentTimeMillis();

		this.fps = (4 * this.fps + 1000 / (this.t2 - this.t1 + 1)) / 5;

		if (result == -1)
		{
			LOG.error("Timeout when requesting image!");
			return false;
		}
		else if (result < -1)
		{
			LOG.error("Error requesting new frame!");
			return false;
		}

		return true;
	}

	@Override
	public void run()
	{

		do
		{

			if (Thread.interrupted())
			{
				LOG.debug("Refresher has been interrupted");
				return;
			}

			if (!this.open.get())
			{
				LOG.debug("Cancelling refresher");
				return;
			}

			if (!this.updateFrameBuffer())
			{
				this.FAULTY = true;
			}

		}
		while (this.open.get());
	}

	@Override
	public double getFPS()
	{
		return this.fps;
	}
}