/*******************************************************************************
 * Copyright (c) 2010 Attila Kovacs <attila_kovacs[AT]post.harvard.edu>.
 * All rights reserved. 
 * 
 * This file is part of crush.
 * 
 *     crush 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 3 of the License, or
 *     (at your option) any later version.
 * 
 *     crush 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 crush.  If not, see <http://www.gnu.org/licenses/>.
 * 
 * Contributors:
 *     Attila Kovacs <attila_kovacs[AT]post.harvard.edu> - initial API and implementation
 ******************************************************************************/
// Copyright (c) 2007 Attila Kovacs 

package util;


import java.util.*;

import nom.tam.fits.*;
import nom.tam.util.*;


// TODO Read fits Headers (for extra information on projection parameters)...
// TODO Implement a few more projections...

// Based on Calabretta & Greisen 2002
public abstract class SphericalProjection implements Cloneable {
	protected SphericalCoordinates reference; // the reference in celestial (alpha0, delta0)
	protected SphericalCoordinates referenceNative; // the reference in native (phi0, theta0)
	
	protected SphericalCoordinates pole; // the pole in celestial (alphap, deltap)
	protected SphericalCoordinates poleNative; // the pole in native (phip, thetap)
	
	protected boolean isPolar = false;
	protected boolean rightAnglePole, userSpecifiedPole = false;
	
	public SphericalProjection() {
		referenceNative = new SphericalCoordinates(0.0, 0.0); // phi0, theta0;
		poleNative = new SphericalCoordinates(0.0, 0.0); // phip, thetap;
	} 
	
	@Override
	public boolean equals(Object o) {
		if(!o.getClass().equals(getClass())) return false;
		SphericalProjection projection = (SphericalProjection) o;
		
		if(projection.isPolar != isPolar) return false;
		if(projection.rightAnglePole != rightAnglePole) return false;
		if(!projection.reference.equals(reference)) return false;
		if(!projection.referenceNative.equals(referenceNative)) return false;
		if(!rightAnglePole) if(!projection.pole.equals(pole)) return false;
		if(!projection.poleNative.equals(poleNative)) return false;
		return true;		
	}
	
	@Override
	public int hashCode() {
		int hash = 0;
		if(reference != null) hash ^= reference.hashCode();
		if(referenceNative != null) hash ^= referenceNative.hashCode();
		if(!rightAnglePole) if(pole != null) hash ^= pole.hashCode();
		if(poleNative != null) hash ^= poleNative.hashCode();
		return hash;
	}
	
	@Override
	public Object clone() {
		try { return super.clone(); }
		catch(CloneNotSupportedException e) { return null; }
	}
	
	public SphericalProjection copy() {
		SphericalProjection copy = (SphericalProjection) clone();
		if(reference != null) copy.reference = (SphericalCoordinates) reference.clone();
		if(pole != null) copy.pole = (SphericalCoordinates) pole.clone();
		if(referenceNative != null) copy.referenceNative = (SphericalCoordinates) referenceNative.clone();
		if(poleNative != null) copy.poleNative = (SphericalCoordinates) poleNative.clone();
		return copy;
	}
	
	// Global projection
	public void project(final SphericalCoordinates coords, final CoordinatePair toProjected) {		
		final double dLON = coords.x - pole.x;
		double phi = Double.NaN, theta = Double.NaN;
		
		if(rightAnglePole) {
			if(pole.y > 0.0) {
				phi = poleNative.x + dLON + Math.PI;
				theta = coords.y;
			}
			else {
				phi = poleNative.x - dLON;
				theta = -coords.y;
			}	
		}
		else {
			final double cosdLON = Math.cos(dLON);
			
			phi = poleNative.x + Math.atan2(
					-coords.cosLat * Math.sin(dLON),
					coords.sinLat * pole.cosLat - coords.cosLat * pole.sinLat * cosdLON);
			
			theta = asin(coords.sinLat * pole.sinLat + coords.cosLat * pole.cosLat * cosdLON);
		}
	
		phi = Math.IEEEremainder(phi, fullTurn);
		
		//System.err.println(Util.f2.format(phi/Unit.deg) + ", " + Util.f2.format(theta/Unit.deg));
		
		getOffsets(theta, phi, toProjected);
	}
	
	// Global deprojection
	public void deproject(final CoordinatePair projected, final SphericalCoordinates toCoords) {
		final double theta = theta(projected);
		final double phi = phi(projected);
		final double dPhi = phi - poleNative.x;
		
		if(rightAnglePole) {
			if(pole.y > 0.0) {
				toCoords.x = pole.x + dPhi - Math.PI;
				toCoords.y = theta;
			}
			else {
				toCoords.x = pole.x - dPhi;
				toCoords.y = -theta;
			}	
		}
		else {
			final double cosTheta = Math.cos(theta);
			final double sinTheta = Math.sin(theta);
			final double cosPhi = Math.cos(dPhi);
					
			toCoords.setNativeLongitude(pole.x + Math.atan2(
					-cosTheta * Math.sin(dPhi),
					sinTheta * pole.cosLat - cosTheta * pole.sinLat * cosPhi));	
			
			toCoords.setNativeLatitude(asin(sinTheta * pole.sinLat + cosTheta * pole.cosLat * cosPhi));
		}
		
		toCoords.standardize();
	}
	
	public abstract String getFitsID();
	
	public abstract String getFullName();
	
	
	public abstract double phi(CoordinatePair offset);
	
	public abstract double theta(CoordinatePair offset);
	
	public abstract void getOffsets(double theta, double phi, CoordinatePair toOffset);
	
	public void setReference(SphericalCoordinates coordinates) {
		setReference(coordinates, referenceNative);
	}
	
	public void setReference(SphericalCoordinates celestialCoords, SphericalCoordinates nativeCoords) throws IllegalArgumentException { 
		reference = celestialCoords; 
		referenceNative = nativeCoords; 
		
		if(userSpecifiedPole) return;
		
		poleNative.setNativeLongitude(reference.y >= referenceNative.y ? 0 : Math.PI); 
			
		if(isPolar) {
			pole = reference;
			rightAnglePole = SphericalCoordinates.equalAngles(Math.abs(pole.y), quarterTurn);
			return;
		}
		
		pole = new SphericalCoordinates();	
		
		double sindPhi = Math.sin(poleNative.x - referenceNative.x);
		double cosdPhi = Math.cos(poleNative.x - referenceNative.x);
		
		double deltap = Math.atan2(referenceNative.sinLat, referenceNative.cosLat * cosdPhi);
		double cs = referenceNative.cosLat * sindPhi;
		
		double deltab = acos(reference.sinLat / Math.sqrt(1.0 - cs*cs));
		
		double delta1 = deltap + deltab;
		double delta2 = deltap - deltab;
		
		// make delta2 > delta1
		if(delta1 > delta2) {
			double temp = delta2;
			delta2 = delta1;
			delta1 = temp;
		}
		
		int solutions = 0;
		// Tries to select the northern pole solution
		// TODO
		// Otherwise should check FITS keyword and chose pole nearest to it...
		if(Math.abs(delta1) <= quarterTurn) { pole.setNativeLatitude(delta1); solutions++; }
		if(Math.abs(delta2) <= quarterTurn) {
			// If two solutions exists, chose the more northern one.
			if(solutions == 0 || delta2 > delta1) pole.setNativeLatitude(delta2); 
			solutions++;
		}
		if(solutions == 0) throw new IllegalArgumentException("No solutions for celestial pole.");
		
		
		//System.err.println(solutions + " solution(s)");
		
		if(SphericalCoordinates.equalAngles(Math.abs(reference.y), quarterTurn)) pole.x = reference.x;
		else if(SphericalCoordinates.equalAngles(Math.abs(pole.y), quarterTurn)) {
			pole.setNativeLongitude(reference.x + (pole.y > 0 ? 
						poleNative.x - referenceNative.x - Math.PI :
						referenceNative.x - poleNative.x));
		}
		else {
			double sindLON = sindPhi * referenceNative.cosLat / reference.cosLat;
			double cosdLON = (referenceNative.sinLat - pole.sinLat * reference.sinLat) / (pole.cosLat * reference.cosLat);
			pole.setNativeLongitude(reference.x - Math.atan2(sindLON, cosdLON));
		}
		
		pole.standardize();
		
		rightAnglePole = SphericalCoordinates.equalAngles(Math.abs(pole.y), quarterTurn);
	}
	
	
	public SphericalCoordinates getReference() { return reference; }
	
	public void setPole(SphericalCoordinates celestialCoords) {
		setPole(celestialCoords, poleNative);
	}
	
	public void setPole(SphericalCoordinates celestialCoords, SphericalCoordinates nativeCoords) {
		userSpecifiedPole = true;
		pole = celestialCoords;
		poleNative = nativeCoords;
		rightAnglePole = SphericalCoordinates.equalAngles(Math.abs(pole.y), quarterTurn);
	}
	
	public SphericalCoordinates getNativePole() { return poleNative; }
	
	public SphericalCoordinates getCelestialPole() { return pole; }
	
	public void setDefaultPole() {
		userSpecifiedPole = false;
		poleNative.zero();
		setReference(getReference());
	}
	
	public CoordinatePair getProjected(SphericalCoordinates coords) {
		CoordinatePair offset = new CoordinatePair();
		project(coords, offset);
		return offset;		
	}
	
	public SphericalCoordinates getDeprojected(Vector2D projected) {
		SphericalCoordinates coords = new SphericalCoordinates();
		deproject(projected, coords);
		return coords;		
	}
	
	
	public void edit(Cursor cursor) throws HeaderCardException { edit(cursor, ""); }
	
	public void edit(Cursor cursor, String alt) throws HeaderCardException {		
		// TODO
		// *** Added
		//if(!isPolar) cursor.add(new HeaderCard("LATPOLE" + alt, pole.y / Unit.deg, "The latitude (deg) of the celestial pole."));
		
		for(int i=0; i<reference.coordinateSystem.size(); i++) {
			CoordinateAxis axis = reference.coordinateSystem.get(i);
			// This approach of overwriting FITS keys via the cursor has been
			// tested, and works even if there is not prior key...
			cursor.setKey("CTYPE" + (i+1) + alt);
			cursor.remove();
			cursor.add(new HeaderCard("CTYPE" + (i+1) + alt, axis.wcsName.substring(0,4) + "-" + getFitsID(), axis.label + " in " + getFullName()) + " projection.");
		}
	}
	
	public void parse(Header header) { parse(header, ""); }
	
	public void parse(Header header, String alt) {
		// TODO Auto-generated method stub	
	}
	
	
	// Safe asin and acos for when rounding errors make values fall outside of -1:1 range.
	protected final static double asin(double value) {
		if(value < -1.0) value = -1.0;
		else if(value > 1.0) value = 1.0;
		return Math.asin(value);
	}
	
	protected final static double acos(double value) {
		if(value < -1.0) value = -1.0;
		else if(value > 1.0) value = 1.0;
		return Math.acos(value);
	}
	
	static Hashtable<String, SphericalProjection> registry;
	
	static {
		registry = new Hashtable<String, SphericalProjection>();
		register(new SlantOrthographic()); // SIN
		register(new Gnomonic()); // TAN
		register(new ZenithalEqualArea()); // ZEA
		register(new SansonFlamsteed()); // SFL
		register(new Mercator()); // MER
		register(new PlateCarree()); // CAR
		register(new HammerAitoff()); // AIT
		register(new RadioProjection()); // GLS
		//register(new AIPSLegacyProjection(new SansonFlamsteed(), "Radio", "GLS"));
	}
	
	public static void register(SphericalProjection projection) {
		registry.put(projection.getFitsID(), projection);
	}
	
	// Find projection by FITS name, full name, or class name...
	public static SphericalProjection forName(String name) throws InstantiationException, IllegalAccessException {
		SphericalProjection projection = registry.get(name);
		if(projection != null) return projection.getClass().newInstance();
		
		Collection<SphericalProjection> projections = registry.values();
		
		for(SphericalProjection p : projections) {
			if(p.getFitsID().equals("name")) return p.getClass().newInstance();
			else if(p.getFullName().equalsIgnoreCase("name")) return p.getClass().newInstance();
			else if(p.getClass().getSimpleName().equals("name")) return p.getClass().newInstance();
		}
		throw new InstantiationException("No projection " + name + " in registry.");
	}


	public final static double fullTurn = 2.0 * Math.PI;
	public final static double halfTurn = Math.PI;
	public final static double quarterTurn = 0.5 * Math.PI;

}
