/*
 *                    BioJava development code
 *
 * This code may be freely distributed and modified under the
 * terms of the GNU Lesser General Public Licence.  This should
 * be distributed with the code.  If you do not have a copy,
 * see:
 *
 *      http://www.gnu.org/copyleft/lesser.html
 *
 * Copyright for this code is held jointly by the individual
 * authors.  These should be listed in @author doc comments.
 *
 * For more information on the BioJava project and its aims,
 * or to join the biojava-l mailing list, visit the home page
 * at:
 *
 *      http://www.biojava.org/
 *
 */
package org.biojava.nbio.structure.xtal;

import javax.vecmath.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Locale;

//import org.slf4j.Logger;
//import org.slf4j.LoggerFactory;

/**
 * A crystal cell's parameters.
 *
 * @author duarte_j
 *
 */
public class CrystalCell implements Serializable {

	private static final long serialVersionUID = 1L;
	//private static final Logger logger = LoggerFactory.getLogger(CrystalCell.class);

	public static final double MIN_VALID_CELL_SIZE = 10.0; // the minimum admitted for a crystal cell


	private double a;
	private double b;
	private double c;

	private double alpha;
	private double beta;
	private double gamma;

	private double alphaRad;
	private double betaRad;
	private double gammaRad;

	private double volume; // cached volume

	private double maxDimension; // cached max dimension

	private Matrix3d M; 	// cached basis change transformation matrix
	private Matrix3d Minv;  // cached basis change transformation matrix
	private Matrix3d Mtransp;
	private Matrix3d MtranspInv;

	public CrystalCell() {

	}

	public CrystalCell(double a, double b, double c, double alpha, double beta, double gamma){
		this.a = a;
		this.b = b;
		this.c = c;
		this.setAlpha(alpha); // initialises both alpha and alphaRad
		this.setBeta(beta);
		this.setGamma(gamma);
	}

	public double getA() {
		return a;
	}

	public void setA(double a) {
		this.a = a;
	}

	public double getB() {
		return b;
	}

	public void setB(double b) {
		this.b = b;
	}

	public double getC() {
		return c;
	}

	public void setC(double c) {
		this.c = c;
	}

	public double getAlpha() {
		return alpha;
	}

	public void setAlpha(double alpha) {
		this.alpha = alpha;
		this.alphaRad = Math.toRadians(alpha);
	}

	public double getBeta() {
		return beta;
	}

	public void setBeta(double beta) {
		this.beta = beta;
		this.betaRad = Math.toRadians(beta);
	}

	public double getGamma() {
		return gamma;
	}

	public void setGamma(double gamma) {
		this.gamma = gamma;
		this.gammaRad = Math.toRadians(gamma);
	}

	/**
	 * Returns the volume of this unit cell.
	 * See http://en.wikipedia.org/wiki/Parallelepiped
	 * @return
	 */
	public double getVolume() {
		if (volume!=0) {
			return volume;
		}
		volume =  a*b*c*
		Math.sqrt(1-Math.cos(alphaRad)*Math.cos(alphaRad)-Math.cos(betaRad)*Math.cos(betaRad)-Math.cos(gammaRad)*Math.cos(gammaRad)
				+2.0*Math.cos(alphaRad)*Math.cos(betaRad)*Math.cos(gammaRad));

		return volume;
	}

	/**
	 * Get the index of a unit cell to which the query point belongs.
	 *
	 * <p>For instance, all points in the unit cell at the origin will return (0,0,0);
	 * Points in the unit cell one unit further along the `a` axis will return (1,0,0),
	 * etc.
	 * @param pt Input point (in orthonormal coordinates)
	 * @return A new point with the three indices of the cell containing pt
	 */
	public Point3i getCellIndices(Tuple3d pt) {
		Point3d p = new Point3d(pt);
		this.transfToCrystal(p);

		int x = (int)Math.floor(p.x);
		int y = (int)Math.floor(p.y);
		int z = (int)Math.floor(p.z);
		return new Point3i(x,y,z);
	}

	/**
	 * Converts the coordinates in pt so that they occur within the (0,0,0) unit cell
	 * @param pt
	 */
	public void transfToOriginCell(Tuple3d pt) {
		transfToCrystal(pt);

		// convert all coordinates to [0,1) interval
		pt.x = pt.x<0 ? (pt.x%1.0 + 1.0)%1.0 : pt.x%1.0;
		pt.y = pt.y<0 ? (pt.y%1.0 + 1.0)%1.0 : pt.y%1.0;
		pt.z = pt.z<0 ? (pt.z%1.0 + 1.0)%1.0 : pt.z%1.0;

		transfToOrthonormal(pt);
	}

	/**
	 * Converts a set of points so that the reference point falls in the unit cell.
	 *
	 * This is useful to transform a whole chain at once, allowing some of the
	 * atoms to be outside the unit cell, but forcing the centroid to be within it.
	 *
	 * @param points A set of points to transform (in orthonormal coordinates)
	 * @param reference The reference point, which is unmodified but which would
	 *    be in the unit cell were it to have been transformed. It is safe to
	 *    use a member of the points array here.
	 */
	public void transfToOriginCell(Tuple3d[] points, Tuple3d reference) {
		reference = new Point3d(reference);//clone
		transfToCrystal(reference);

		int x = (int)Math.floor(reference.x);
		int y = (int)Math.floor(reference.y);
		int z = (int)Math.floor(reference.z);

		for( Tuple3d point: points ) {
			transfToCrystal(point);
			point.x -= x;
			point.y -= y;
			point.z -= z;
			transfToOrthonormal(point);
		}
	}

	/**
	 *
	 * @param ops Set of operations in orthonormal coordinates
	 * @param reference Reference point, which should be in the unit cell after
	 *  each operation (also in orthonormal coordinates)
	 * @return A set of orthonormal operators with equivalent rotation to the
	 *  inputs, but with translation such that the reference point would fall
	 *  within the unit cell
	 */
	public Matrix4d[] transfToOriginCellOrthonormal(Matrix4d[] ops, Tuple3d reference) {
		// reference in crystal coords
		Point3d refXtal = new Point3d(reference);
		transfToCrystal(refXtal);

		// Convert to crystal coords
		Matrix4d[] opsXtal = new Matrix4d[ops.length];
		for(int i=0;i<ops.length;i++) {
			opsXtal[i] = transfToCrystal(ops[i]);
		}

		Matrix4d[] transformed = transfToOriginCellCrystal(opsXtal, refXtal);

		// Convert back to orthonormal
		for(int i=0;i<ops.length;i++) {
			transformed[i] = transfToOrthonormal(transformed[i]);
		}
		return transformed;
	}
	/**
	 *
	 * @param ops Set of operations in crystal coordinates
	 * @param reference Reference point, which should be in the unit cell after
	 *  each operation (also in crystal coordinates)
	 * @return A set of crystal operators with equivalent rotation to the
	 *  inputs, but with translation such that the reference point would fall
	 *  within the unit cell
	 */
	public Matrix4d[] transfToOriginCellCrystal(Matrix4d[] ops, Tuple3d reference) {
		Matrix4d[] transformed = new Matrix4d[ops.length];
		for(int j=0;j<ops.length;j++) {
			Matrix4d op = ops[j];

			// transform the reference point
			Point3d xXtal = new Point3d(reference);
			op.transform(xXtal);

			// Calculate unit cell of transformed reference
			int x = (int)Math.floor(xXtal.x);
			int y = (int)Math.floor(xXtal.y);
			int z = (int)Math.floor(xXtal.z);

			Matrix4d translation = new Matrix4d();
			translation.set(new Vector3d(-x,-y,-z));

			// Compose op with an additional translation operator
			translation.mul(op);

			Point3d ref2 = new Point3d(reference);
			translation.transform(ref2);

			transformed[j] = translation;
		}

		return transformed;
	}

	/**
	 * Transform given Matrix4d in crystal basis to the orthonormal basis using
	 * the PDB axes convention (NCODE=1)
	 * @param m
	 * @return
	 */
	public Matrix4d transfToOrthonormal(Matrix4d m) {
		Vector3d trans = new Vector3d(m.m03,m.m13,m.m23);
		transfToOrthonormal(trans);

		Matrix3d rot = new Matrix3d();
		m.getRotationScale(rot);
		// see Giacovazzo section 2.E, eq. 2.E.1
		// Rprime = MT-1 * R * MT
		rot.mul(this.getMTranspose());
		rot.mul(this.getMTransposeInv(),rot);

		return new Matrix4d(rot,trans,1.0);
	}

	/**
	 * Transforms the given crystal basis coordinates into orthonormal coordinates.
	 * e.g. transfToOrthonormal(new Point3d(1,1,1)) returns the orthonormal coordinates of the
	 * vertex of the unit cell.
	 * See Giacovazzo section 2.E, eq. 2.E.1 (or any linear algebra manual)
	 * @param v
	 */
	public void transfToOrthonormal(Tuple3d v) {
		// see Giacovazzo section 2.E, eq. 2.E.1
		getMTransposeInv().transform(v);
	}

	/**
	 * Transform given Matrix4d in orthonormal basis to the crystal basis using
	 * the PDB axes convention (NCODE=1)
	 * @param m
	 * @return
	 */
	public Matrix4d transfToCrystal(Matrix4d m) {
		Vector3d trans = new Vector3d(m.m03,m.m13,m.m23);
		transfToCrystal(trans);

		Matrix3d rot = new Matrix3d();
		m.getRotationScale(rot);
		// see Giacovazzo section 2.E, eq. 2.E.1 (the inverse equation)
		// R = MT * Rprime * MT-1
		rot.mul(this.getMTransposeInv());
		rot.mul(this.getMTranspose(),rot);

		return new Matrix4d(rot,trans,1.0);
	}

	/**
	 * Transforms the given orthonormal basis coordinates into crystal coordinates.
	 * See Giacovazzo eq 2.20 (or any linear algebra manual)
	 * @param v
	 */
	public void transfToCrystal(Tuple3d v) {
		getMTranspose().transform(v);
	}

	/**
	 * Returns the change of basis (crystal to orthonormal) transform matrix, that is
	 * M inverse in the notation of Giacovazzo.
	 * Using the PDB axes convention
	 * (CCP4 uses NCODE identifiers to distinguish the different conventions, the PDB one is called NCODE=1)
	 * The matrix is only calculated upon first call of this method, thereafter it is cached.
	 * See "Fundamentals of Crystallography" C. Giacovazzo, section 2.5 (eq 2.30)
	 *
	 * The non-standard orthogonalisation codes (NCODE for ccp4) are flagged in REMARK 285 after 2011's remediation
	 * with text: "THE ENTRY COORDINATES ARE NOT PRESENTED IN THE STANDARD CRYSTAL FRAME". There were only 148 PDB
	 * entries with non-standard code in 2011. See:
	 * http://www.wwpdb.org/documentation/2011remediation_overview-061711.pdf
	 * The SCALE1,2,3 records contain the correct transformation matrix (what Giacovazzo calls M matrix).
	 * In those cases if we calculate the M matrix following Giacovazzo's equations here, we get an entirely wrong one.
	 * Examples of PDB with non-standard orthogonalisation are 1bab and 1bbb.
	 * @return
	 */
	private Matrix3d getMInv() {
		if (Minv!=null) {
			return Minv;
		}

		// see eq. 2.30 Giacovazzo
		Minv =  new Matrix3d(                    this.a,                                           0,              0,
							  this.b*Math.cos(gammaRad),                   this.b*Math.sin(gammaRad),              0,
							  this.c*Math.cos(betaRad) , -this.c*Math.sin(betaRad)*getCosAlphaStar(),  1.0/getCstar());
		return Minv;
	}

	// another axes convention (from Giacovazzo) eq. 2.31b, not sure what would be the NCODE of this
//	private Matrix3d getMInv() {
//		if (Minv!=null) {
//			return Minv;
//		}
//		Minv = new Matrix3d( 1/getAstar(),  -(getCosGammaStar()/getSinGammaStar())/getAstar(), this.a*Math.cos(betaRad),
//				                        0,                   1/(getBstar()*getSinGammaStar()), this.b*Math.sin(alphaRad),
//				                        0,                                                  0,                  this.c);
//		return Minv;
//	}

	// and yet another axes convention (from Giacovazzo) eq. 2.31c, not sure what would be the NCODE of this
//	private Matrix3d getMInv() {
//		if (Minv!=null) {
//			return Minv;
//		}
//		Minv = new Matrix3d( alphaRad*Math.sin(gammaRad)*getSinBetaStar(), this.a*Math.cos(gammaRad), this.a*Math.sin(gammaRad)*getCosBetaStar(),
//				                                                        0,                    this.b,                                          0,
//				                                                        0, this.c*Math.cos(alphaRad),                   this.c*Math.sin(alphaRad));
//		return Minv;
//	}

	// relationships among direct and reciprocal lattice parameters
	// see Table 2.1 of chapter 2 of Giacovazzo
	@SuppressWarnings("unused")
	private double getAstar() {
		return (this.b*this.c*Math.sin(alphaRad))/getVolume();
	}

	@SuppressWarnings("unused")
	private double getBstar() {
		return (this.a*this.c*Math.sin(betaRad))/getVolume();
	}

	private double getCstar() {
		return (this.a*this.b*Math.sin(gammaRad))/getVolume();
	}

	private double getCosAlphaStar() {
		return (Math.cos(betaRad)*Math.cos(gammaRad)-Math.cos(alphaRad))/(Math.sin(betaRad)*Math.sin(gammaRad));
	}

	@SuppressWarnings("unused")
	private double getCosBetaStar() {
		return (Math.cos(alphaRad)*Math.cos(gammaRad)-Math.cos(betaRad))/(Math.sin(alphaRad)*Math.sin(gammaRad));
	}

	@SuppressWarnings("unused")
	private double getCosGammaStar() {
		return (Math.cos(alphaRad)*Math.cos(betaRad)-Math.cos(gammaRad))/(Math.sin(alphaRad)*Math.sin(betaRad));
	}

	@SuppressWarnings("unused")
	private double getSinAlphaStar() {
		return getVolume()/(this.a*this.b*this.c*Math.sin(betaRad)*Math.sin(gammaRad));
	}

	@SuppressWarnings("unused")
	private double getSinBetaStar() {
		return getVolume()/(this.a*this.b*this.c*Math.sin(alphaRad)*Math.sin(gammaRad));
	}

	@SuppressWarnings("unused")
	private double getSinGammaStar() {
		return getVolume()/(this.a*this.b*this.c*Math.sin(alphaRad)*Math.sin(betaRad));
	}

	/**
	 * Returns the change of basis (orthonormal to crystal) transform matrix, that is
	 * M in the notation of Giacovazzo.
	 * Using the PDB convention (NCODE=1).
	 * The matrix is only calculated upon first call of this method, thereafter it is cached.
	 * See "Fundamentals of Crystallography" C. Giacovazzo, section 2.5
	 * @return
	 */
	private Matrix3d getM() {
		if (M!=null){
			return M;
		}
		M = new Matrix3d();
		M.invert(getMInv());
		return M;
	}

	public Matrix3d getMTranspose() {
		if (Mtransp!=null){
			return Mtransp;
		}
		Matrix3d M = getM();
		Mtransp = new Matrix3d();
		Mtransp.transpose(M);
		return Mtransp;
	}

	private Matrix3d getMTransposeInv() {
		if (MtranspInv!=null){
			return MtranspInv;
		}
		MtranspInv = new Matrix3d();
		MtranspInv.invert(getMTranspose());
		return MtranspInv;
	}

	/**
	 * Gets the maximum dimension of the unit cell.
	 * @return
	 */
	public double getMaxDimension() {
		if (maxDimension!=0) {
			return maxDimension;
		}
		Point3d vert0 = new Point3d(0,0,0);
		Point3d vert1 = new Point3d(1,0,0);
		transfToOrthonormal(vert1);
		Point3d vert2 = new Point3d(0,1,0);
		transfToOrthonormal(vert2);
		Point3d vert3 = new Point3d(0,0,1);
		transfToOrthonormal(vert3);
		Point3d vert4 = new Point3d(1,1,0);
		transfToOrthonormal(vert4);
		Point3d vert5 = new Point3d(1,0,1);
		transfToOrthonormal(vert5);
		Point3d vert6 = new Point3d(0,1,1);
		transfToOrthonormal(vert6);
		Point3d vert7 = new Point3d(1,1,1);
		transfToOrthonormal(vert7);

		ArrayList<Double> vertDists = new ArrayList<Double>();
		vertDists.add(vert0.distance(vert7));
		vertDists.add(vert3.distance(vert4));
		vertDists.add(vert1.distance(vert6));
		vertDists.add(vert2.distance(vert5));
		maxDimension = Collections.max(vertDists);
		return maxDimension;
	}

	/**
	 * Given a scale matrix parsed from the PDB entry (SCALE1,2,3 records),
	 * checks that the matrix is a consistent scale matrix by comparing the
	 * cell volume to the inverse of the scale matrix determinant (tolerance of 1/100).
	 * If they don't match false is returned.
	 * See the PDB documentation for the SCALE record.
	 * See also last equation of section 2.5 of "Fundamentals of Crystallography" C. Giacovazzo
	 * @param scaleMatrix
	 * @return
	 */
	public boolean checkScaleMatrixConsistency(Matrix4d scaleMatrix) {

		double vol = getVolume();
		Matrix3d m = new Matrix3d();
		scaleMatrix.getRotationScale(m);

		// note we need to have a relaxed tolerance here as the PDB scale matrix is given with not such high precision
		// plus we don't want to have false positives, so we stay conservative
		double tolerance = vol/100.0;
		if ((Math.abs(vol - 1.0/m.determinant() )>tolerance)) {
			//System.err.println("Warning! SCALE matrix from PDB does not match 1/determinat == cell volume: "+
			//		String.format("vol=%6.3f  1/det=%6.3f",vol,1.0/m.determinant()));
			return false;
		}
		// this would be to check our own matrix, must always match!
		//if (!deltaComp(vol,1.0/getMTranspose().determinant())) {
		//	System.err.println("Our calculated SCALE matrix does not match 1/det=cell volume");
		//}

		return true;

	}

	/**
	 * Given a scale matrix parsed from a PDB entry (SCALE1,2,3 records),
	 * compares it to our calculated Mtranspose matrix to see if they coincide and
	 * returns true if they do.
	 * If they don't that means that the PDB entry is not in the standard
	 * orthogonalisation (NCODE=1 in ccp4).
	 * In 2011's remediation only 148 PDB entries were found not to be in
	 * a non-standard orthogonalisation. See:
	 * http://www.wwpdb.org/documentation/2011remediation_overview-061711.pdf
	 * For normal cases the scale matrix is diagonal without a translation component.
	 * Additionally the translation component of the SCALE matrix is also checked to
	 * make sure it is (0,0,0), if not false is return
	 * @param scaleMatrix
	 * @return
	 */
	public boolean checkScaleMatrix(Matrix4d scaleMatrix) {

		Matrix3d mtranspose = getMTranspose();
		for (int i=0;i<3;i++) {
			for (int j=0;j<3;j++) {
				if (!deltaComp(mtranspose.getElement(i, j),scaleMatrix.getElement(i, j))) {
					//System.out.println("Our value   ("+i+","+j+"): "+getM().getElement(i,j));
					//System.out.println("Their value ("+i+","+j+"): "+scaleMatrix.getElement(i,j));
					return false;
				}
			}
		}
		for (int i=0;i<3;i++) {
			if (!deltaComp(scaleMatrix.getElement(i, 3),0)) {
				return false;
			}
		}
		return true;
	}

	private boolean deltaComp(double d1, double d2) {
		if (Math.abs(d1-d2)<0.0001) return true;
		return false;
	}

	/**
	 * Checks whether the dimensions of this crystal cell are reasonable for protein
	 * crystallography: if all 3 dimensions are below {@value #MIN_VALID_CELL_SIZE} the cell
	 * is considered unrealistic and false returned
	 * @return
	 */
	public boolean isCellReasonable() {
		// this check is necessary mostly when reading PDB files that can contain the default 1 1 1 crystal cell
		// if we use that further things can go wrong, for instance for interface calculation
		// For instance programs like coot produce by default a 1 1 1 cell

		if (this.getA()<MIN_VALID_CELL_SIZE &&
				this.getB()<MIN_VALID_CELL_SIZE &&
				this.getC()<MIN_VALID_CELL_SIZE) {
			return false;
		}

		return true;

	}

	@Override
	public String toString() {
		return String.format(Locale.US, "a%7.2f b%7.2f c%7.2f alpha%6.2f beta%6.2f gamma%6.2f", a, b, c, alpha, beta, gamma);
	}
}
