package uk.ac.starlink.ttools.plot2;

import java.awt.Color;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.Shape;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import javax.swing.Icon;

/**
 * Aggregates a Surface and the Rectangle that it is placed within.
 * It may also store decorations to be painted on top of the plot.
 * Class instances themselves may be compared for equality, but don't
 * do much else.  Several static methods however are provided to assist
 * in creating instances, in particular doing the non-trivial work to
 * determine how much external space is required for legends etc.
 *
 * <p>Note instances of this class are not immutable, since the decoration
 * list may be changed.
 *
 * @author   Mark Taylor
 * @since    12 Feb 2013
 */
@Equality
public class PlotPlacement {

    private final Rectangle bounds_;
    private final Surface surface_;
    private final List<Decoration> decorations_;

    /** Default external padding in pixels to accommodate axis line width. */
    public static final int PAD = 2;

    private static final int EXTERNAL_LEGEND_GAP = 10;
    private static final int MIN_DIM = 24;

    /**
     * Constructs a placement with no decorations.
     *
     * @param   bounds   external bounds within which plot is to be placed
     * @param   surface  plot surface
     */
    public PlotPlacement( Rectangle bounds, Surface surface ) {
        this( bounds, surface, new Decoration[ 0 ] );
    }

    /**
     * Constructs a placement with an initial list of decorations.
     *
     * @param   bounds   external bounds within which plot is to be placed
     * @param   surface  plot surface
     * @param   decorations  initial list of decorations;
     *          note more can be added later
     */
    public PlotPlacement( Rectangle bounds, Surface surface,
                          Decoration[] decorations ) {
        bounds_ = bounds;
        surface_ = surface;
        decorations_ =
            new ArrayList<Decoration>( Arrays.asList( decorations ) );
    }

    /**
     * Returns the external bounds of this placement.
     *
     * @return   bounds
     */
    public Rectangle getBounds() {
        return bounds_;
    }

    /**
     * Returns the plot surface.
     *
     * @return  surface
     */
    public Surface getSurface() {
        return surface_;
    }

    /**
     * Returns a list of decorations to be painted over the finished plot.
     * This list may be modified to add or remove decoration items.
     *
     * @return   modifiable list of decoration objects
     */
    public List<Decoration> getDecorations() {
        return decorations_;
    }

    /**
     * Takes an icon containing plot background and layers,
     * and turns it into one positioned in an external rectangle
     * with surface foreground (axes) and other decorations.
     *
     * @param  dataIcon  icon as generated by
     *       {@link uk.ac.starlink.ttools.plot2.paper.PaperType#createDataIcon}
     * @return  final plot icon to be drawn at the graphics origin
     */
    public Icon createPlotIcon( final Icon dataIcon ) {
        final Rectangle plotBounds = surface_.getPlotBounds();
        return new Icon() {
            public int getIconWidth() {
                return bounds_.width;
            }
            public int getIconHeight() {
                return bounds_.height;
            }
            public void paintIcon( Component c, Graphics g, int x, int y ) {
                int xoff = x - bounds_.x;
                int yoff = y - bounds_.y;
                g.translate( xoff, yoff );
                paintPlot( c, g );
                g.translate( -xoff, -yoff );
            }
            private void paintPlot( Component c, Graphics g ) {
                Shape clip0 = g.getClip();
                g.clipRect( plotBounds.x, plotBounds.y,
                            plotBounds.width, plotBounds.height );
                dataIcon.paintIcon( c, g, plotBounds.x, plotBounds.y );
                g.setClip( clip0 );
                surface_.paintForeground( g );
                for ( Decoration dec : decorations_ ) {
                    dec.paintDecoration( g );
                }
            }
        };
    }

    @Override
    public boolean equals( Object o ) {
        if ( o instanceof PlotPlacement ) {
            PlotPlacement other = (PlotPlacement) o;
            return this.bounds_.equals( other.bounds_ )
                && this.surface_.equals( other.surface_ )
                && this.decorations_.equals( other.decorations_ );
        }
        else {
            return false;
        }
    }

    @Override
    public int hashCode() {
        int code = 239991;
        code = 23 * code + bounds_.hashCode();
        code = 23 * code + surface_.hashCode();
        code = 23 * code + decorations_.hashCode();
        return code;
    }

    /**
     * Convenience method to create a plot placement given various inputs.
     * In particular it works out how much space is required for
     * decorations like axis annotations, legend etc.
     *
     * @param   extBounds  external bounds of plot placement
     * @param   padding   requirements for outer padding, or null
     * @param   surfFact  surface factory
     * @param   profile  factory-specific surface profile
     * @param   aspect   factory-specific surface aspect
     * @param   withScroll  true if the placement should work well
     *                      with future scrolling
     * @param   trimming   specification for static decorations, or null
     * @param   shadeAxis  shader axis if required, or null
     * @return   new plot placement
     */
    public static <P,A> PlotPlacement
            createPlacement( Rectangle extBounds, Padding padding,
                             SurfaceFactory<P,A> surfFact,
                             P profile, A aspect, boolean withScroll,
                             Trimming trimming, ShadeAxis shadeAxis ) {
        Rectangle dataBounds =
            calculateDataBounds( extBounds, padding, surfFact, profile, aspect,
                                 withScroll, trimming, shadeAxis );
        Surface surf = surfFact.createSurface( dataBounds, profile, aspect );
        PlotFrame frame = PlotFrame.createPlotFrame( surf, withScroll );
        Decoration[] decs = createPlotDecorations( frame, trimming, shadeAxis );
        return new PlotPlacement( extBounds, surf, decs );
    }

    /**
     * Calculates the space surrounding a plot required for trimming and
     * shade axis.  These do not depend much on the size of the plot bounds.
     *
     * @param   extBounds  external bounds of plot placement
     * @param   trimming  specification for static decorations, or null
     * @param   shadeAxis  shader axis if required, or null
     * @param   capFact   supplier for a Captioner;
     *                    may return null if no title is required
     * @return   padding space required to accommodate decorations
     */
    public static Surround
            calculateApproxDecorationSurround( Rectangle extBounds,
                                               Trimming trimming,
                                               ShadeAxis shadeAxis,
                                               Supplier<Captioner> capFact ) {
        Icon legend = trimming == null ? null : trimming.getLegend();
        float[] legPos = trimming == null ? null : trimming.getLegendPosition();
        String title = trimming == null ? null : trimming.getTitle();
        boolean hasExtLegend = legend != null && legPos == null;
        Surround decSurround = new Surround();
        if ( hasExtLegend ) {
            decSurround.right.extent =
                Math.max( decSurround.right.extent,
                          legend.getIconWidth() + EXTERNAL_LEGEND_GAP );
            decSurround.right.extent =
                Math.min( decSurround.right.extent,
                          extBounds.width / 2 );
        }
        if ( shadeAxis != null ) {
            Rectangle rampBox =
                new Rectangle( 0, 0, shadeAxis.getRampWidth(),
                               extBounds.height );
            decSurround.right.extent =
                Math.max( decSurround.right.extent,
                          rampBox.width
                        + shadeAxis.getRampInsets( rampBox ).right
                        + EXTERNAL_LEGEND_GAP );
            int yShadePad = shadeAxis.getEndPadding();
            if ( ! hasExtLegend ) {
                decSurround.right.under =
                    Math.max( decSurround.right.under, yShadePad );
            }
            decSurround.right.over =
                Math.max( decSurround.right.under, yShadePad );
        }
        if ( title != null ) {
            Captioner captioner = capFact.get();
            if ( captioner != null ) {
                Caption caption = Caption.createCaption( title );
                int titleHeight = captioner.getCaptionBounds( caption ).height
                                + captioner.getPad();
                decSurround.top.extent =
                    Math.max( decSurround.top.extent, titleHeight );
            }
        }
        return decSurround;
    }

    /**
     * Determines the required insets for a plot to accommodate
     * axis annotations etc.
     *
     * @param   extBounds  external bounds of plot placement
     * @param   surfFact  surface factory
     * @param   profile  factory-specific surface profile
     * @param   aspect   factory-specific surface aspect
     * @param   withScroll  true if the placement should work well
     *                      with future scrolling
     * @param   trimming  specification for static decorations, or null
     * @param   shadeAxis  shader axis if required, or null
     * @param   pad   extra padding in pixels around the outside
     * @return  data bounds rectangle
     */
    public static <P,A> Insets
            calculateDataInsets( Rectangle extBounds,
                                 SurfaceFactory<P,A> surfFact,
                                 P profile, A aspect, boolean withScroll,
                                 Trimming trimming, ShadeAxis shadeAxis,
                                 int pad ) {

        /* Slightly annoying that we have to create a surface just to
         * get the captioner, but it should be cheap. */
        Supplier<Captioner> capFact =
            () -> surfFact.createSurface( extBounds, profile, aspect )
                          .getCaptioner();

        /* Calculate surrounding space required for decorations that
         * do not depend (much) on the size of the plot bounds. */
        Surround decSurround =
            calculateApproxDecorationSurround( extBounds, trimming, shadeAxis,
                                               capFact );

        /* Insets for padding outside space that is actually painted on
         * by the axes and decorations. */
        Insets padInsets = new Insets( PAD, PAD, PAD, PAD );

        /* Now work out the insets that do depend on the actual size
         * of the plot.  Since the size of the plot depends on the insets, 
         * which is what we're trying to work out, it's not straightforward
         * to get the answer.  We have to supply a guess for the insets,
         * work out the size of the internal plotting surface based on that,
         * and then ask the surface how much space is required for axis
         * annotations given that.  But the positions of the labels might
         * be different in the plot surface if the resulting insets are used,
         * so really it's necessary to iterate.
         * Iterating until stability is a bad idea, since it might not
         * become stable.  So do it a few times and return the insets
         * representing the biggest required space.  This still isn't
         * bulletproof, but there's a pretty good chance it will give
         * enough space for the labels. */

        /* Assign the number of required iterations.
         * If scrolling is being accounted for, it's likely
         * (though not certain) that just one iteration will do it,
         * since that allocates extra space on the assumptions that
         * the labels might move around.  But for withScroll=false,
         * the change in the position of the labels is more likely to
         * affect the result, so do more iterations.
         * withScroll=false is also likely to be for generating an
         * image for output (not interactive) so the extra time it takes
         * (shouldn't be very expensive in any case) is not so much of
         * an issue. */
        int nit = withScroll ? 2 : 5;

        /* Iterate. */
        Surround axisSurround = new Surround();
        Insets plotInsets = null;
        for ( int i = 0; i < nit; i++ ) {
            plotInsets = decSurround.add( axisSurround ).toInsets();
            Rectangle plotBounds = extBounds;
            plotBounds = PlotUtil.subtractInsets( plotBounds, padInsets );
            plotBounds = PlotUtil.subtractInsets( plotBounds, plotInsets );
            plotBounds.width = Math.max( plotBounds.width, MIN_DIM );
            plotBounds.height = Math.max( plotBounds.height, MIN_DIM );
            Surface surf =
                surfFact.createSurface( plotBounds, profile, aspect );
            axisSurround = axisSurround.union( surf.getSurround( withScroll ) );
        }
        assert plotInsets != null;

        /* Add fixed padding and return. */
        return new Insets( plotInsets.top + padInsets.top,
                           plotInsets.left + padInsets.left,
                           plotInsets.bottom + padInsets.bottom,
                           plotInsets.right + padInsets.right );
    }

    /**
     * Determines the bounds for the data part of a plot given its
     * external dimensions and other information about it.
     * It does this by assessing how much space will be required for
     * axis annotations etc.
     *
     * @param   extBounds  external bounds of plot placement
     * @param   padding   preferences for outer padding, or null
     * @param   surfFact  surface factory
     * @param   profile  factory-specific surface profile
     * @param   aspect   factory-specific surface aspect
     * @param   withScroll  true if the placement should work well
     *                      with future scrolling
     * @param   trimming  specification for static decorations, or null
     * @param   shadeAxis  shader axis if required, or null
     * @return  data bounds rectangle
     */
    public static <P,A> Rectangle
            calculateDataBounds( Rectangle extBounds, Padding padding,
                                 SurfaceFactory<P,A> surfFact, P profile,
                                 A aspect, boolean withScroll,
                                 Trimming trimming, ShadeAxis shadeAxis ) {
        padding = padding == null ? new Padding() : padding;
        final Insets insets;
        if ( padding.isDefinite() ) {
            insets = padding.toDefiniteInsets();
        }
        else {
            Insets dataInsets =
                calculateDataInsets( extBounds, surfFact, profile, aspect,
                                     withScroll, trimming, shadeAxis, PAD );
            insets = padding.overrideInsets( dataInsets );
        }
        Rectangle plotBounds = PlotUtil.subtractInsets( extBounds, insets );
        plotBounds.width = Math.max( plotBounds.width, MIN_DIM );
        plotBounds.height = Math.max( plotBounds.height, MIN_DIM );
        return plotBounds;
    }

    /**
     * Returns a list of plot decorations for things like the legend
     * and shade colour ramp.
     *
     * @param  frame   geometry of area to be decorated
     * @param   trimming  specification for static decorations, or null
     * @param   shadeAxis  shader axis if required, or null
     * @return   list of decorations (may have zero elements)
     */
    public static Decoration[] createPlotDecorations( PlotFrame frame,
                                                      Trimming trimming,
                                                      ShadeAxis shadeAxis ) {

        Rectangle intBounds = frame.getInternalBounds();
        Surround surround = frame.getSurround();
        Captioner captioner = frame.getCaptioner();
        Icon legend = trimming == null ? null : trimming.getLegend();
        float[] legPos = trimming == null ? null : trimming.getLegendPosition();
        String title = trimming == null ? null : trimming.getTitle();
        List<Decoration> decList = new ArrayList<Decoration>();
        int gxlo = intBounds.x;
        int gylo = intBounds.y;
        int gxhi = intBounds.x + intBounds.width;
        int gyhi = intBounds.y + intBounds.height;
        int xRightExtra = gxhi + surround.right.extent + EXTERNAL_LEGEND_GAP;
        int yTopExtra = gylo - surround.top.extent;
        
        /* Work out legend position. */
        if ( legend != null ) {
            final int lx;
            final int ly;
            if ( legPos == null ) {
                lx = xRightExtra;
                ly = gylo;
            }
            else {
                lx = gxlo + Math.round( ( gxhi - gxlo - legend.getIconWidth() )
                                      * legPos[ 0 ] );

                /* Invert the sense of the y component so that up is positive,
                 * like for data coordinates. */
                ly = gylo + Math.round( ( gyhi - gylo - legend.getIconHeight() )
                                      * ( 1f - legPos[ 1 ] ) );
            }
            decList.add( new Decoration( legend, lx, ly ) );
        }

        /* Work out shader axis position. */
        if ( shadeAxis != null ) {
            int sx = xRightExtra;
            boolean hasExtLegend = legend != null && legPos == null;
            int sy = gylo;
            if ( hasExtLegend ) {
                sy += legend.getIconHeight()
                    + Math.max( shadeAxis.getEndPadding() + PAD,
                                EXTERNAL_LEGEND_GAP );
            }
            Rectangle rampBox =
                new Rectangle( sx, sy, shadeAxis.getRampWidth(), gyhi - sy );
            Icon shadeIcon = shadeAxis.createAxisIcon( rampBox );
            decList.add( new Decoration( shadeIcon, sx, sy ) );
        }

        /* Position title. */
        if ( title != null ) {
            Icon titleIcon = new CaptionIcon( title, captioner );
            int px = intBounds.x
                   + intBounds.width / 2
                   - titleIcon.getIconWidth() / 2;
            int py = yTopExtra - titleIcon.getIconHeight()
                               - captioner.getPad();
            decList.add( new Decoration( titleIcon, px, py ) );
        }

        return decList.toArray( new Decoration[ 0 ] );
    }

    /**
     * Icon which draws a caption.
     */
    @Equality
    private static class CaptionIcon implements Icon {
        private final Caption caption_;
        private final Captioner captioner_;
        private final int width_;
        private final int height_;
        private final int x_;
        private final int y_;

        /**
         * Constructor.
         *
         * @param  text  caption text
         * @param  captioner   caption painter
         */ 
        CaptionIcon( String text, Captioner captioner ) {
            caption_ = Caption.createCaption( text );
            captioner_ = captioner;
            Rectangle bounds = captioner.getCaptionBounds( caption_ );
            x_ = 0;
            y_ = - bounds.y;
            width_ = bounds.width;
            height_ = bounds.height;
        }

        public int getIconWidth() {
            return width_;
        }

        public int getIconHeight() {
            return height_;
        }

        public void paintIcon( Component c, Graphics g, int x, int y ) {
            int xoff = x + x_;
            int yoff = y + y_;
            Color color0 = g.getColor();
            g.setColor( Color.BLACK );
            g.translate( xoff, yoff );
            captioner_.drawCaption( caption_, g );
            g.translate( -xoff, -yoff );
            g.setColor( color0 );
        }

        @Override
        public int hashCode() {
            int code = 4432;
            code = 23 * caption_.hashCode();
            code = 23 * captioner_.hashCode();
            return code;
        }

        @Override
        public boolean equals( Object o ) {
            if ( o instanceof CaptionIcon ) {
                CaptionIcon other = (CaptionIcon) o;
                return this.caption_.equals( other.caption_ )
                    && this.captioner_.equals( other.captioner_ );
            }
            else {
                return false;
            }
        }
    }
}
