/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache 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.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.wicket.extensions.captcha.kittens;

import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.image.BufferedImage;
import java.awt.image.RescaleOp;
import java.io.IOException;
import java.io.Serializable;
import java.lang.ref.SoftReference;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import javax.imageio.ImageIO;
import javax.imageio.stream.MemoryCacheImageInputStream;
import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxCallListener;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.attributes.IAjaxCallListener;
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.JavaScriptHeaderItem;
import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
import org.apache.wicket.markup.head.OnEventHeaderItem;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.image.Image;
import org.apache.wicket.markup.html.image.NonCachingImage;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.request.IRequestParameters;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.http.WebResponse;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.request.resource.DynamicImageResource;
import org.apache.wicket.request.resource.JavaScriptResourceReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A unique and fun-to-use captcha technique I developed at Thoof.
 * 
 * @author Jonathan Locke
 */
public class KittenCaptchaPanel extends Panel
{
	private static final long serialVersionUID = 2711167040323855070L;

	private static final Logger LOG = LoggerFactory.getLogger(KittenCaptchaPanel.class);

	// The background grass area
	private static BufferedImage grass = load("images/grass.png");

	// The kittens and other animals
	private static final List<Animal> kittens = new ArrayList<>();
	private static final List<Animal> nonKittens = new ArrayList<>();

	// Random number generator
	private static Random random = new Random(-1);

	// Load animals
	static
	{
		kittens.add(new Animal("kitten_01", true));
		kittens.add(new Animal("kitten_02", true));
		kittens.add(new Animal("kitten_03", true));
		kittens.add(new Animal("kitten_04", true));
		nonKittens.add(new Animal("chick", false));
		nonKittens.add(new Animal("guinea_pig", false));
		nonKittens.add(new Animal("hamster", false));
		nonKittens.add(new Animal("puppy", false));
		nonKittens.add(new Animal("rabbit", false));
	}

	/**
	 * @param filename
	 *            The name of the file to load
	 * @return The image read form the file
	 */
	private static BufferedImage load(final String filename)
	{
		try
		{
			return ImageIO.read(new MemoryCacheImageInputStream(
				KittenCaptchaPanel.class.getResourceAsStream(filename)));
		}
		catch (IOException e)
		{
			LOG.error("Error loading image", e);
			return null;
		}
	}

	/**
	 * The various animals as placed animals
	 */
	private final PlacedAnimalList animals;

	/**
	 * Label that shows request status
	 */
	private final Label animalSelectionLabel;

	/**
	 * The image component
	 */
	private final Image image;

	/**
	 * The image resource referenced by the Image component
	 */
	private final CaptchaImageResource imageResource;

	/**
	 * Size of this kitten panel's image
	 */
	private final Dimension imageSize;

	/**
	 * @param id
	 *            Component id
	 * @param imageSize
	 *            Size of kitten captcha image
	 */
	public KittenCaptchaPanel(final String id, final Dimension imageSize)
	{
		super(id);

		// Save image size
		this.imageSize = imageSize;

		// Create animal list
		animals = new PlacedAnimalList();

		// Need to ajax refresh
		setOutputMarkupId(true);

		// Show how many animals have been selected
		animalSelectionLabel = new Label("animalSelectionLabel", new IModel<String>()
		{
			@Override
			public String getObject()
			{
				return imageResource.selectString();
			}
		});
		animalSelectionLabel.setOutputMarkupId(true);
		add(animalSelectionLabel);

		// Image referencing captcha image resource
		image = new NonCachingImage("image", imageResource = new CaptchaImageResource(animals));
		image.add(new AjaxEventBehavior("click")
		{
			private static final long serialVersionUID = 7480352029955897654L;

			@Override
			protected void updateAjaxAttributes(AjaxRequestAttributes attributes)
			{
				super.updateAjaxAttributes(attributes);
				IAjaxCallListener ajaxCallListener = new AjaxCallListener() {
					@Override
					public CharSequence getBeforeSendHandler(Component component)
					{
						return "showLoadingIndicator();";
					}
				};
				attributes.getAjaxCallListeners().add(ajaxCallListener);
				List<CharSequence> dynamicExtraParameters = attributes.getDynamicExtraParameters();
				dynamicExtraParameters.add("return { x: getEventX(Wicket.$(attrs.c), attrs.event), y: getEventY(Wicket.$(attrs.c), attrs.event)}");
			}

			@Override
			protected void onEvent(final AjaxRequestTarget target)
			{
				// Get clicked cursor position
				final Request request = RequestCycle.get().getRequest();
				IRequestParameters requestParameters = request.getRequestParameters();
				final int x = requestParameters.getParameterValue("x").toInt(0);
				final int y = requestParameters.getParameterValue("y").toInt(0);

				// Force refresh
				imageResource.clearData();

				// Find any animal at the clicked location
				final PlacedAnimal animal = animals.atLocation(new Point(x, y));

				// If the user clicked on an animal
				if (animal != null)
				{
					// Toggle the animal's highlighting
					animal.isHighlighted = !animal.isHighlighted;

					// Instead of reload entire image just change the src
					// attribute, this reduces the flicker
					final StringBuilder javascript = new StringBuilder();
					javascript.append("Wicket.$('")
						.append(image.getMarkupId())
						.append("').src = '");
					CharSequence url = image.urlForListener(new PageParameters());
					javascript.append(url);
					javascript.append(url.toString().indexOf('?') > -1 ? "&amp;" : "?")
						.append("rand=")
						.append(Math.random());
					javascript.append("'");
					target.appendJavaScript(javascript.toString());
				}
				else
				{
					// The user didn't click on an animal, so hide the loading
					// indicator
					target.appendJavaScript(" hideLoadingIndicator();");
				}

				// Update the selection label
				target.add(animalSelectionLabel);
			}
		});
		add(image);
	}

	/**
	 * @return True if all (three) kittens have been selected
	 */
	public boolean allKittensSelected()
	{
		return imageResource.allKittensSelected();
	}

	/**
	 * Resets for another go-around
	 */
	public void reset()
	{
		imageResource.reset();
	}

	/**
	 * @param animals
	 *            List of animals
	 * @param newAnimal
	 *            New animal to place
	 * @return The placed animal
	 */
	private PlacedAnimal placeAnimal(final List<PlacedAnimal> animals, final Animal newAnimal)
	{
		// Try 100 times
		for (int iter = 0; iter < 100; iter++)
		{
			// Get the new animal's width and height
			final int width = newAnimal.image.getWidth();
			final int height = newAnimal.image.getHeight();

			// Pick a random position
			final int x = random(imageSize.width - width);
			final int y = random(imageSize.height - height);
			final Point point = new Point(x, y);

			// Determine if there is too much overlap with other animals
			final double tooClose = new Point(width, height).distance(new Point(0, 0)) / 2.0;
			boolean tooMuchOverlap = false;
			for (final PlacedAnimal animal : animals)
			{
				if (point.distance(animal.location) < tooClose)
				{
					tooMuchOverlap = true;
					break;
				}
			}

			// If there was not too much overlap
			if (!tooMuchOverlap)
			{
				// The animal is now placed at x, y
				return new PlacedAnimal(newAnimal, new Point(x, y));
			}
		}

		// Could not place animal
		return null;
	}
	
	@Override
	public void renderHead(IHeaderResponse response)
	{
		super.renderHead(response);
		response.render(JavaScriptHeaderItem.forReference(
			new JavaScriptResourceReference(KittenCaptchaPanel.class, "kittencaptcha.js")));
		response.render(OnEventHeaderItem.forComponent(image, "load", "hideLoadingIndicator()"));
		response.render(OnDomReadyHeaderItem.forScript("if (document.getElementById('"
			+ image.getMarkupId() + "').complete) hideLoadingIndicator();"));
	}

	/**
	 * @param max
	 *            Maximum size of random value
	 * @return A random number between 0 and max - 1
	 */
	private int random(final int max)
	{
		return Math.abs(random.nextInt(max));
	}

	/**
	 * @return A random kitten
	 */
	private Animal randomKitten()
	{
		return kittens.get(random(kittens.size()));
	}

	/**
	 * @return A random other animal
	 */
	private Animal randomNonKitten()
	{
		return nonKittens.get(random(nonKittens.size()));
	}

	/**
	 * Animal, whether kitten or non-kitten
	 */
	private static class Animal
	{
		/**
		 * The highlighted image
		 */
		private final BufferedImage highlightedImage;

		/**
		 * The normal image
		 */
		private final BufferedImage image;

		/**
		 * True if the animal is a kitten
		 */
		private final boolean isKitten;

		/**
		 * The visible region of the animal
		 */
		private final OpaqueRegion visibleRegion;

		/**
		 * @param filename
		 *            The filename
		 * @param isKitten
		 *            True if the animal is a kitten
		 */
		private Animal(final String filename, final boolean isKitten)
		{
			this.isKitten = isKitten;
			image = load("images/" + filename);
			highlightedImage = load("images/" + filename + "_highlight");
			visibleRegion = new OpaqueRegion(image);
		}

		/**
		 * @param filename
		 *            The file to load
		 * @return The image in the file
		 */
		private BufferedImage load(final String filename)
		{
			try
			{
				final BufferedImage loadedImage = ImageIO.read(new MemoryCacheImageInputStream(
					KittenCaptchaPanel.class.getResourceAsStream(filename + ".png")));
				final BufferedImage image = new BufferedImage(loadedImage.getWidth(),
					loadedImage.getHeight(), BufferedImage.TYPE_INT_ARGB);
				final Graphics2D graphics = image.createGraphics();
				graphics.drawImage(loadedImage, 0, 0, null);
				graphics.dispose();
				return image;
			}
			catch (IOException e)
			{
				LOG.error("Error loading image", e);
				return null;
			}
		}
	}

	/**
	 * Resource which renders the actual captcha image
	 */
	private static class CaptchaImageResource extends DynamicImageResource
	{
		private static final long serialVersionUID = -1560784998742404278L;

		/**
		 * The placed animals
		 */
		private final PlacedAnimalList animals;

		/**
		 * Image data array
		 */
		private transient SoftReference<byte[]> data = null;

		@Override
		protected void configureResponse(final ResourceResponse response,
			final Attributes attributes)
		{
			super.configureResponse(response, attributes);
			response.disableCaching();
		}

		/**
		 * @param animals
		 *            The positioned animals
		 */
		private CaptchaImageResource(final PlacedAnimalList animals)
		{
			this.animals = animals;
			setFormat("jpg");
		}

		/**
		 * @return Rendered image data
		 */
		@Override
		protected byte[] getImageData(final Attributes attributes)
		{
			// Handle caching
			setLastModifiedTime(Instant.now());
			final WebResponse response = (WebResponse)RequestCycle.get().getResponse();
			response.setHeader("Cache-Control", "no-cache, must-revalidate, max-age=0, no-store");

			// If we don't have data
			if ((data == null) || (data.get() == null))
			{
				// Create the image and turn it into data
				final BufferedImage composedImage = animals.createImage();
				data = new SoftReference<>(toImageData(composedImage));
			}

			// Return image data
			return data.get();
		}

		/**
		 * Invalidates the image data
		 */
		protected void invalidate()
		{
			data = null;
		}

		/**
		 * @return True if all kittens have been selected
		 */
		private boolean allKittensSelected()
		{
			return animals.allKittensSelected();
		}

		/**
		 * Clears out image data
		 */
		private void clearData()
		{
			invalidate();
			setLastModifiedTime(Instant.now());
		}

		/**
		 * Resets animals to default states
		 */
		private void reset()
		{
			animals.reset();
		}

		/**
		 * @return Selection state string for animals
		 */
		private String selectString()
		{
			return animals.selectString();
		}
	}

	/**
	 * An animal that has a location
	 */
	private static class PlacedAnimal implements Serializable
	{
		private static final long serialVersionUID = -6703909440564862486L;

		/**
		 * The animal
		 */
		private transient Animal animal;

		/**
		 * Index in kitten or nonKitten list
		 */
		private final int index;

		/**
		 * True if the animal is highlighted
		 */
		private boolean isHighlighted;

		/**
		 * True if this animal is a kitten
		 */
		private final boolean isKitten;

		/**
		 * The location of the animal
		 */
		private final Point location;

		/**
		 * Scaling values
		 */
		private final float[] scales = { 1f, 1f, 1f, 1f };

		/**
		 * @param animal
		 *            The animal
		 * @param location
		 *            Where to put it
		 */
		public PlacedAnimal(final Animal animal, final Point location)
		{
			this.animal = animal;
			this.location = location;
			isKitten = animal.isKitten;
			if (isKitten)
			{
				index = kittens.indexOf(animal);
			}
			else
			{
				index = nonKittens.indexOf(animal);
			}
			for (int i = 0; i < 3; i++)
			{
				scales[i] = random(0.9f, 1.0f);
			}
			scales[3] = random(0.7f, 1.0f);
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public String toString()
		{
			return (isKitten ? "kitten at " : "other at ") + location.x + ", " + location.y;
		}

		/**
		 * @param point
		 *            The point
		 * @return True if this placed animal contains the given point
		 */
		private boolean contains(final Point point)
		{
			final Point relativePoint = new Point(point.x - location.x, point.y - location.y);
			return getAnimal().visibleRegion.contains(relativePoint);
		}

		/**
		 * @param graphics
		 *            The graphics to draw on
		 */
		private void draw(final Graphics2D graphics)
		{
			final float[] offsets = new float[4];
			final RescaleOp rop = new RescaleOp(scales, offsets, null);
			if (isHighlighted)
			{
				graphics.drawImage(getAnimal().highlightedImage, rop, location.x, location.y);
			}
			else
			{
				graphics.drawImage(getAnimal().image, rop, location.x, location.y);
			}
		}

		/**
		 * @return The animal that is placed
		 */
		private Animal getAnimal()
		{
			if (animal == null)
			{
				if (isKitten)
				{
					animal = kittens.get(index);
				}
				else
				{
					animal = nonKittens.get(index);
				}
			}
			return animal;
		}

		/**
		 * @param min
		 *            Minimum random value
		 * @param max
		 *            Maximum random value
		 * @return A random value in the given range
		 */
		private float random(final float min, final float max)
		{
			return min + Math.abs(random.nextFloat() * (max - min));
		}
	}

	/**
	 * Holds a list of placed animals
	 */
	private class PlacedAnimalList implements Serializable
	{
		private static final long serialVersionUID = 6335852594326213439L;

		/**
		 * List of placed animals
		 */
		private final List<PlacedAnimal> animals = new ArrayList<>();

		/**
		 * Arrange random animals and kittens
		 */
		private PlacedAnimalList()
		{
			// Place the three kittens
			animals.add(placeAnimal(animals, randomKitten()));
			animals.add(placeAnimal(animals, randomKitten()));
			animals.add(placeAnimal(animals, randomKitten()));

			// Try a few times
			for (int iter = 0; iter < 500; iter++)
			{
				// Place a non kitten
				final PlacedAnimal animal = placeAnimal(animals, randomNonKitten());

				// If we were able to place the animal
				if (animal != null)
				{
					// add it to the list
					animals.add(animal);
				}

				// 15 non-kittens is enough
				if (animals.size() > 15)
				{
					break;
				}
			}

			// Shuffle the animal order
			Collections.shuffle(animals);

			// Ensure kittens are visible enough
			List<PlacedAnimal> strayKittens = new ArrayList<>();
			for (final PlacedAnimal animal : animals)
			{
				// If it's a kitten
				if (animal.isKitten)
				{
					// Compute the area of the visible region in pixels
					final int kittenArea = animal.getAnimal().visibleRegion.areaInPixels();

					// If at least 4/5ths of the given kitten is not visible
					// (because it is obscured by other animal(s))
					if (visibleRegion(animal).areaInPixels() < kittenArea * 4 / 5)
					{
						// The user probably can't identify it, so add to the
						// stray kittens list
						strayKittens.add(animal);
					}
				}
			}

			// Remove any the stray kittens and then re-add them so they move to
			// the top of the z-order
			animals.removeAll(strayKittens);
			animals.addAll(strayKittens);
		}

		/**
		 * @return True if all kittens are selected
		 */
		private boolean allKittensSelected()
		{
			for (final PlacedAnimal animal : animals)
			{
				if (animal.isKitten != animal.isHighlighted)
				{
					return false;
				}
			}
			return true;
		}

		/**
		 * @param location
		 *            The cursor location that was clicked
		 * @return Any animal that might be at the given location or null if none found (the user
		 *         clicked on grass)
		 */
		private PlacedAnimal atLocation(final Point location)
		{
			// Reverse list for z-ordered hit-testing
			final List<PlacedAnimal> reversedAnimals = new ArrayList<>(animals);
			Collections.reverse(reversedAnimals);

			// Return any animal at the given location
			for (final PlacedAnimal animal : reversedAnimals)
			{
				if (animal.contains(location))
				{
					return animal;
				}
			}

			// No animal found
			return null;
		}

		/**
		 * @return The kitten captcha image
		 */
		private BufferedImage createImage()
		{
			// Create image of the right size
			final BufferedImage newImage = new BufferedImage(imageSize.width, imageSize.height,
				BufferedImage.TYPE_INT_RGB);

			// Draw the grass
			final Graphics2D graphics = newImage.createGraphics();
			graphics.drawImage(grass, 0, 0, null);

			// Draw each animal in order
			for (final PlacedAnimal animal : animals)
			{
				animal.draw(graphics);
			}

			// Clean up graphics resource
			graphics.dispose();

			// Return the rendered animals
			return newImage;
		}

		/**
		 * Undo highlight states of animals
		 */
		private void reset()
		{
			for (final PlacedAnimal animal : animals)
			{
				animal.isHighlighted = false;
			}
		}

		/**
		 * @return Selection string to show
		 */
		private String selectString()
		{
			int selected = 0;
			for (final PlacedAnimal animal : animals)
			{
				if (animal.isHighlighted)
				{
					selected++;
				}
			}
			if (selected == 0)
			{
				return getString("instructions");
			}
			else
			{
				return selected + " " + getString("animalsSelected");
			}
		}

		/**
		 * @param animal
		 *            The animal
		 * @return The visible region of the animal
		 */
		private OpaqueRegion visibleRegion(final PlacedAnimal animal)
		{
			// The index of the animal in the animal list
			int index = animals.indexOf(animal);

			// Check sanity
			if (index == -1)
			{
				// Invalid animal somehow
				throw new IllegalArgumentException("animal not in list");
			}
			else
			{
				// Get the animal's visible region
				OpaqueRegion visible = animal.getAnimal().visibleRegion;

				// Go through the animals above the given animal
				for (index++; index < animals.size(); index++)
				{

					// Remove the higher animal's visible region
					final PlacedAnimal remove = animals.get(index);
					visible = visible.subtract(remove.getAnimal().visibleRegion, new Point(
						remove.location.x - animal.location.x, remove.location.y -
							animal.location.y));
				}
				return visible;
			}
		}
	}
}
