package org.khelekore.jsizer;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.swing.BorderFactory;
import javax.swing.JPanel;
import javax.swing.border.Border;

/** A panel that paints class sizes. */
class SizePanel extends JPanel {
    private List<ClassSize> classSizes;
    private List<TreeNode> treeNodes = new ArrayList<TreeNode> ();
    private int maxFields;
    private int maxMethods;
    private int maxLines;
    private int totalWidth;
    private int totalHeight;
    private SizeFilter sizeFilter;
    private ClassNameFilter classNameFilter; 
    private double sx, sy; // current scale factors.
    // class name -> base color
    private Map<String, Color> colorMap = new HashMap<String, Color> (); 
    
    private Map<Color, Color> colorCache = new HashMap<Color, Color> ();

    /** never scale graphics up more than this, it looks silly. */
    private static final int MAX_SCALE = 2;
    private static final int HEIGHT_PAD = 20;
    private static final int LEGEND_HEIGHT = 12 * 2;
    private static final int LEGEND_BOX_HEIGHT = LEGEND_HEIGHT / 2;
    private static final int LEGEND_BOX_WIDTH = 45;
    private static final int LEGEND_BOX_SPACING = 5;

    private static Font baseFont = new Font ("Arial", Font.PLAIN, 0);

    private static final long serialVersionUID = 1;
    
    public SizePanel (List<ClassSize> classSizes) {
	this.classSizes = classSizes;
	setPreferredSize (new Dimension (500, 500));
	setOpaque (true);
	setBackground (Color.WHITE);
	Border b1 = BorderFactory.createLineBorder (Color.BLACK, 1);
	Border b2 = BorderFactory.createEmptyBorder (5, 5, 5, 5);
	Border b3 = BorderFactory.createCompoundBorder (b1, b2);
	setBorder (b3);
	setToolTipText ("");
	addMouseListener (new PopupHandler (this));
    }

    public String getToolTipText (MouseEvent event) {
	List<TreeNode> tns = getNodesAt (event);
	return getToolTip (tns);
    }

    /** Get the sequence of nodes that form a path down to the mouse position.
     * @return a list of TreeNode or an empty list if no node is at the mouse position.
     */
    public List<TreeNode> getNodesAt (MouseEvent event) {
	Insets insets = getInsets ();
	int mx = event.getX () - insets.left;
	int my = event.getY () - insets.top;
	double x = mx / sx;
	double y = my / sy;
	
	synchronized (this) {
	    for (TreeNode tn : treeNodes)  {
		if (mayContain (tn, x, y)) {
		    List<TreeNode> tns = new ArrayList<TreeNode> ();
		    tns.add (tn);
		    if (buildNodeList (tn, x, y, tns)) 
			return tns;
		}
	    }
	}
	return Collections.emptyList ();
    }

    private String getToolTip (List<TreeNode> tns) {
	if (tns.isEmpty ())
	    return null;

	StringBuilder sb = new StringBuilder ();
	for (TreeNode tn : tns) {
	    if (sb.length () > 0)
		sb.append ('.');
	    sb.append (tn.getName ());
	}
	TreeNode tn = tns.get (tns.size () - 1);
	return getToolTip (tn, sb.toString ());
    }
    
    private boolean mayContain (TreeNode tn, double x, double y) {
	Point2D p = tn.getCenterTop ();
	if (y < p.getY ())
	    return false;
	double w = tn.getWidthWithChildren () / 2.0;
	return ((p.getX () - w) <= x && (p.getX () + w) >= x);
    }

    private boolean buildNodeList (TreeNode p, double x, double y, 
				   List<TreeNode> tns) {
	Rectangle r = p.getRectangle ();
	if (r.contains (x, y))
	    return true;
	for (TreeNode c : p.getChildren ()) {
	    if (mayContain (c, x, y)) {
		tns.add (c);
		if (buildNodeList (c, x, y, tns))
		    return true;
	    }
	}
	return false;
    }

    private String getToolTip (TreeNode tn, String name) {
	if (tn.getZ () < 0) 
	    return "<html><body><b>" + name + "</b></body></html>";
	return "<html><body><b>" + name + "</b><br>" +
	    "Fields: " + tn.getWidth () + "<br>" + 
	    "Methods: " + tn.getHeight () + "<br>" + 
	    "Method Lines: " + tn.getZ () + 
	    "</body></html>";
    }

    public void setSizeFilter (SizeFilter sizeFilter) {
	this.sizeFilter = sizeFilter;
	buildTree ();
    }

    public SizeFilter getSizeFilter () {
	return sizeFilter;
    }

    public void setClassNameFilter (ClassNameFilter classNameFilter) {
	this.classNameFilter = classNameFilter;
	buildTree ();
    }

    public ClassNameFilter getClassNameFilter () {
	return classNameFilter;
    }

    public List<ClassSize> getClassSizes () {
	return Collections.unmodifiableList (classSizes);
    }

    public void addColor (String classname, Color color) {
	colorMap.put (classname, color);
    }

    public void removeColor (String classname) {
	colorMap.remove (classname);
    }

    public void clearColors () {
	colorMap.clear ();
    }

    public void rebuild () {
	buildTree ();
    }

    private void clearMaxValues () {
	maxFields = 0;
	maxMethods = 0;
	maxLines = 0;
    }

    private void updateMaxValues (ClassSize cs) {
	maxFields = Math.max (maxFields, cs.getNumFields ());
	maxMethods = Math.max (maxMethods, cs.getNumMethods ());
	maxLines = Math.max (maxLines, cs.getNumMethodLines ());
    }

    private void setMaxValues () {
	clearMaxValues ();
	for (ClassSize cs : classSizes)
	    updateMaxValues (cs);	
    }

    public void setClassSizes (List<ClassSize> classSizes) {
	this.classSizes = classSizes;
	buildTree ();
    }
    
    private synchronized void buildTree () {
	// make sure that we get classes before inner classes... 
	List<ClassSize> clss = classSizes;
	Collections.sort (clss, new ClzNameSorter ());
	if (sizeFilter != null || classNameFilter != null)
	    clss = findFilteredClasses ();
	else 
	    setMaxValues ();

	treeNodes = new ArrayList<TreeNode> ();
	for (ClassSize cs : clss)
	    addNode (cs, maxLines);

	// align the top.
	int startHeight = 0;
	for (TreeNode tn : treeNodes) 
	    startHeight = Math.max (startHeight, tn.getGraphicalHeight ());
	totalWidth = 0; 
	totalHeight = 0;
	for (TreeNode tn : treeNodes) {
	    int startsAt = totalWidth; 
	    // pad one on each side.
	    int w = tn.getWidthWithChildren () + 2;
	    int centerX = startsAt + w / 2;
	    tn.setPosition (centerX, startHeight);
	    setChildPositions (tn, HEIGHT_PAD);
	    totalWidth += w;
	}
	repaint ();
    }

    // return the index of the last class name splitter, 
    // return -1 if none found.
    private int indexOfLastSplitter (String s) {
	return Math.max (s.lastIndexOf ('.'), s.lastIndexOf ('$'));
    }

    /** Find classes filtered out, but also add the parent 
     *  classes that we can find. 
     */
    private List<ClassSize> findFilteredClasses () {
	clearMaxValues ();
	List<ClassSize> ls = new ArrayList<ClassSize> (classSizes.size ());
	Map<String, ClassSize> skippedClasses = 
	    new HashMap<String, ClassSize> (classSizes.size ());
	for (ClassSize cs : classSizes) {
	    if (classNameFilter != null && 
		!classNameFilter.accept (cs.getClassName ()))
		continue;
	    if (sizeFilter == null || sizeFilter.accept (cs)) {
		updateMaxValues (cs);		
		ls.add (cs);
		String cn = cs.getClassName ();
		int i = indexOfLastSplitter (cn);
		int j = 1;
		while (i > 0) {   // need at least one char before '.'
		    cn = cn.substring (0, i);
		    ClassSize pcs = skippedClasses.get (cn);
		    if (pcs == null) // already in...
			break;
		    ls.add (ls.size () - j, pcs);
		    i = indexOfLastSplitter (cn);
		}
	    } else {
		skippedClasses.put (cs.getClassName (), cs);
	    }
	}
	return ls;
    }

    private static class ClzNameSorter implements Comparator<ClassSize> {
	public int compare (ClassSize cs1, ClassSize cs2) {
	    return cs1.getClassName ().compareTo (cs2.getClassName ());
	}
    }

    private void setChildPositions (TreeNode parent, int heightPad) {
	Point p = parent.getCenterBottom ();
	int w = p.x - parent.getChildrenWidth () / 2;
	List<TreeNode> ls = parent.getChildren ();
	for (TreeNode tn : ls) {
	    int cw = tn.getWidthWithChildren () + 2;
	    int h = tn.getGraphicalHeight () / 2;
	    tn.setPosition (w + cw / 2, p.y + heightPad + h);
	    int newHeight = p.y + heightPad + tn.getGraphicalHeight ();
	    totalHeight = Math.max (totalHeight, newHeight);
	    w += cw;
	    setChildPositions (tn, heightPad); 
	}
    }
    
    private void addNode (ClassSize cs, int maxLines) {
	String name = cs.getClassName ();
	String[] names = name.split ("[.$]");
	TreeNode parent = null;
	for (int i = 0; i < names.length - 1; i++) {
	    TreeNode tn = findNode (parent, names[i]);
	    parent = tn;
	}
	String shortName = names[names.length - 1];
	TreeNode tn = new TreeNode (shortName, cs.getNumFields (), 
				    cs.getNumMethods (), 
				    cs.getNumMethodLines (), getColor (cs));
	addNode (parent, tn);
    }

    private Color getCachedColor (Color color) {
	Color cc = colorCache.get (color);
	if (cc == null) {
	    colorCache.put (color, color);
	    cc = color;
	}
	return cc;
    }

    private Color getColor (ClassSize cs) {
	Color b = getColor (cs.getNumMethodLines ());
	if (!colorMap.isEmpty ()) {
	    String cn = cs.getClassName ();
	    Color c = colorMap.get (cn);
	    int i = cn.lastIndexOf ('$');
	    // class name has to have at least one char before $
	    while (c == null && i > 0) {
		cn = cn.substring (0, i);
		c = colorMap.get (cn);
		i = cn.lastIndexOf ('$');
	    }
	    if (c != null) {
		int k1 = c.getRed ();
		double x = (255.0 - k1) / 255;
		int rc = (int)(b.getRed () * x + k1);
		int k2 = c.getGreen ();
		double y = (255.0 - k2) / 255;
		int gc = (int)(b.getGreen () * y + k2);
		int k3 = c.getBlue ();
		double z = (255.0 - k3) / 255;
		int bc = (int)(b.getBlue () * z + k3);
		return getCachedColor (new Color (rc, gc, bc));
	    }
	}
	return b;
    }

    private Color getColor (int numLines) {
	float c = (float)(.9 - (numLines / (double)maxLines) * .9);
	return getCachedColor (new Color (c, c, c));
    }

    private TreeNode findNode (TreeNode parent, String name) {
	List<TreeNode> ls = 
	    parent == null ? treeNodes : parent.getChildren ();
	for (TreeNode tn : ls)
	    if (tn.getName ().equals (name))
		return tn;
	TreeNode ntn = new TreeNode (name);
	addNode (parent, ntn);
	return ntn;
    }

    private void addNode (TreeNode parent, TreeNode tn) {
	if (parent == null)
	    treeNodes.add (tn);
	else 
	    parent.addChild (tn);
    }

    protected void paintComponent (Graphics g) {
	// clear everything... 
	g.setColor (getBackground ());
	g.fillRect (0, 0, getWidth (), getHeight ());
	synchronized (this) {
	    if (treeNodes.isEmpty ())
		return;
	}

	Insets insets = getInsets ();
	int currentWidth = getWidth () - insets.left - insets.right;
	int currentHeight = 
	    getHeight () - insets.top - insets.bottom - LEGEND_HEIGHT;
	
	Graphics2D g2 = (Graphics2D)g;
	g2.setRenderingHint (RenderingHints.KEY_ANTIALIASING, 
			     RenderingHints.VALUE_ANTIALIAS_ON);

	AffineTransform orig = g2.getTransform ();
	g2.translate (insets.left, insets.top);
	sx = Math.min (MAX_SCALE, (double)currentWidth / totalWidth);
	sy = Math.min (MAX_SCALE, (double)currentHeight / totalHeight);
	g2.scale (sx, sy);
	synchronized (this) {
	    for (TreeNode tn : treeNodes)
		drawNodes (g2, tn);
	    AffineTransform paintTransform = g2.getTransform ();
	    g2.setTransform (orig);
	    drawPackageNames (g2, insets, paintTransform);
	    paintLegend (g2, insets);
	}
    }

    private void paintLegend (Graphics2D g2, Insets insets) {
	if (maxLines == 0) // kind of silly to draw lots of black boxes..
	    return;
	g2.translate (insets.left, insets.top);
	Font f = g2.getFont ().deriveFont (Font.BOLD, LEGEND_BOX_HEIGHT);
	g2.setFont (f);
	int y = getHeight () - insets.top - insets.bottom - LEGEND_BOX_HEIGHT;
	int numBoxes = findNumBoxes (insets);
	if (numBoxes > 0)
	    for (int i = 0; i < numBoxes; i++)
		drawColorBox (g2, i, numBoxes, y); 
	g2.setColor (Color.BLACK);
	
	y--; // create extra space...
	String s = "Max fields: " + maxFields;
	Rectangle2D r = 
	    g2.getFont ().getStringBounds (s, g2.getFontRenderContext ());
	g2.drawString (s, 0, y); 
	int x = (int)r.getWidth () + LEGEND_BOX_SPACING;
	g2.drawString ("Max methods: " + maxMethods, x, y); 
	g2.translate (-insets.left, -insets.top);
    }
    
    // use component width to figure out number of boxes, 
    // never more than 10 though.
    private int findNumBoxes (Insets insets) {
	int numBoxes = // last space can be ignored...
	    (getWidth () - insets.left - insets.right + LEGEND_BOX_SPACING) / 
	    (LEGEND_BOX_WIDTH + LEGEND_BOX_SPACING);
	return Math.min (numBoxes, 10);
    }
	
    private void drawColorBox (Graphics2D g2, int i, int max, int y) {
	int lines = max > 1 ? maxLines * i / (max - 1) : maxLines;
	Color c = getColor (lines);
	g2.setColor (c);
	int x = (LEGEND_BOX_WIDTH + LEGEND_BOX_SPACING) * i;
	g2.fillRect (x, y, LEGEND_BOX_WIDTH, LEGEND_BOX_HEIGHT);
	c = getTextColor (c);
	g2.setColor (c);
	String s = Integer.toString (lines);
	Rectangle2D r = 
	    g2.getFont ().getStringBounds (s, g2.getFontRenderContext ());
	int sx = x + LEGEND_BOX_WIDTH / 2 - (int)(r.getWidth () / 2);
	g2.drawString (s, sx, y + LEGEND_BOX_HEIGHT - 1);
    }

    private Color getTextColor (Color c) {
	float[] hsbVals = 
	    Color.RGBtoHSB (c.getRed (), c.getGreen (), c.getBlue (), null);
	return hsbVals[2] > 0.5 ? Color.BLACK : Color.WHITE;
    }

    private void drawNodes (Graphics2D g2, TreeNode tn) {
	Rectangle r = tn.getRectangle ();
	g2.setColor (tn.getColor ());
	g2.fillRect (r.x, r.y, r.width, r.height);
	synchronized (this) {
	    Point pb = tn.getCenterBottom ();
	    for (TreeNode ctn : tn.getChildren ()) {
		g2.setColor (Color.YELLOW);
		Point pt = ctn.getCenterTop ();
		g2.drawLine (pb.x, pb.y, pt.x, pt.y);
		drawNodes (g2, ctn);
	    }
	}
    }

    private void drawPackageNames (Graphics2D g2, Insets insets, 
				   AffineTransform paintTransform) {
	double size = 10 * sy;
	if (size > 4) {
	    Font f = baseFont.deriveFont ((float)size);
	    g2.setFont (f);
	    g2.setColor (Color.BLACK);
	    for (TreeNode tn : treeNodes)
		drawPackageName (g2, tn, insets);
	}
	for (TreeNode tn : treeNodes)
	    drawClassName (g2, tn, insets);
    }
    
    private void drawPackageName (Graphics2D g2, TreeNode tn, Insets insets) {
	if (tn.isPackage ()) {
	    String name = tn.getName ();
	    FontMetrics fm = g2.getFontMetrics ();
	    double width = fm.stringWidth (name) / sx;
	    if (width < tn.getWidthWithChildren () / 2) {
		Point2D p = tn.getCenterBottom ();
		double x;
		if (p.getX () * sx > getWidth () / 2) {
		    x = p.getX () - tn.getGraphicalWidth () / 2.0;
		    x = x * sx + insets.left - 1 - width * sx ;
		} else {
		    x = p.getX () + tn.getGraphicalWidth () / 2.0;
		    x = x * sx  + insets.left + 1;
		}
		double y = p.getY ();
		y = y * sy + insets.top - 1;
		g2.drawString (name, (float)x, (float)y);
	    }
	}
	for (TreeNode ctn : tn.getChildren ()) 
	    drawPackageName (g2, ctn, insets);
    }

    private void drawClassName (Graphics2D g2, TreeNode tn, Insets insets) {
	if (!tn.isPackage ()) {
	    double gw = tn.getGraphicalWidth () * sx;
	    double size = Math.min (13, gw);
	    while (size > 8) {
		double gh = tn.getGraphicalHeight () * sy;
		String name = tn.getName ();
		Font f = baseFont.deriveFont ((float)size);
		size -= 1;
		g2.setFont (f);
		FontMetrics fm = g2.getFontMetrics ();
		double width = fm.stringWidth (name) + 2;
		if (width < gh) {
		    Point2D p = tn.getCenterTop ();
		    AffineTransform orig = g2.getTransform ();
		    g2.translate (insets.left, insets.top);
		    AffineTransform at = new AffineTransform (g2.getTransform ());
		    at.translate (p.getX () * sx, p.getY () * sy);
		    at.quadrantRotate (1);
		    at.translate (1, fm.getAscent () / 2 - 1);
		    g2.setTransform (at);
		    Color c = getTextColor (getColor (tn.getZ ()));
		    g2.setColor (c);
		    g2.drawString (name, 0, 0);
		    g2.setTransform (orig);
		    break;
		}
	    }
	}
	
	for (TreeNode ctn : tn.getChildren ()) 
	    drawClassName (g2, ctn, insets);
    }
}
