/*
 * [Randomiser.java]
 *
 * Summary: Select a "random" quotation from a collection of quotations.
 *
 * Copyright: (c) 2009-2017 Roedy Green, Canadian Mind Products, http://mindprod.com
 *
 * Licence: This software may be copied and used freely for any purpose but military.
 *          http://mindprod.com/contact/nonmil.html
 *
 * Requires: JDK 1.6+
 *
 * Created with: JetBrains IntelliJ IDEA IDE http://www.jetbrains.com/idea/
 *
 * Version History:
 *  1.8 2009-02-06 include go package in ZIP bundle.
 *                 will be inserted, and where on the page it will be inserted.
 *                 this is not a Macro. It is a collection of methods to help select random quotations, random advertisement,
 *                 PSAs, that change periodically. The selections are not random. They produce the same results every time
 *                 the code is run. This allows choices to remain stable for weeks at a time. There is nothing specific to
 *                 ads or quotations in this class.
 */
package com.mindprod.htmlmacros;

import java.io.File;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.zip.CRC32;

/**
 * Select a "random" quotation from a collection of quotations.
 * <p/>
 * Selection is based on the time and the digest of the name of the file where the quotation is being inserted and where on the page it is being inserted.
 * <p/>
 * It is not really random. It is completely reproducible.
 * <p/>
 * This is not a Macro. It is a collection of methods to help select random quotations, random advertisement,
 * PSAs, that change periodically. The selections are not random. They produce the same results every time
 * the code is run. This allows choices to remain stable for weeks at a time. There is nothing specific to
 * ads or quotations in this class.
 *
 * @author Roedy Green, Canadian Mind Products
 * @version 1.8 2009-02-06 include go package in ZIP bundle.
 * @since 2009
 */
class Randomiser
    {
    // ------------------------------ CONSTANTS ------------------------------

    // todo: convert to enum

    /**
     * true if debugging to get extra output
     */
    private static final boolean DEBUGGING = false;

    /**
     * how many milliseconds in a day
     */
    private static final long MILLIS_PER_DAY = 24 * 60 * 60 * 1000L;

    /**
     * how many milliseconds in an hour
     */
    private static final long MILLIS_PER_HOUR = 60 * 60 * 1000L;

    /**
     * previous fileBeingProcessed
     */
    private static File prevFileBeingProcessed;

    /**
     * which invocation on the page is this, 0 is first 1 is second etc.
     */
    private static int sequenceOnPage;

    // -------------------------- STATIC METHODS --------------------------

    /**
     * Calc how frequently change ANY ad/quote, (more frequently than any single ad)
     *
     * @param changeIntervalInMillis is how often we change any given ad/quote
     *
     * @return rotateIntervalInMillis
     */
    private static long calcRotateInterval( long changeIntervalInMillis )
        {
        if ( changeIntervalInMillis >= MILLIS_PER_DAY )
            {
            return MILLIS_PER_DAY;
            }
        else if ( changeIntervalInMillis >= MILLIS_PER_HOUR * 2 )
            {
            return MILLIS_PER_HOUR * 2;
            }
        else if ( changeIntervalInMillis >= MILLIS_PER_HOUR )
            {
            return MILLIS_PER_HOUR;
            }
        else
            {
            return changeIntervalInMillis;
            }
        }

    /**
     * Assign a random ad OR quotation to a file, by producing a selectorIndex
     *
     * @param changeIntervalInMillis how often to change the ad or quotation.
     * @param fileBeingProcessed     the file currently being processed.
     *
     * @return random index 0..possibilitiies-1, -1 if possibilities == 0
     */
    static int getHashForFile( final long changeIntervalInMillis, final File fileBeingProcessed )
        {
        try
            {
            if ( fileBeingProcessed.equals( prevFileBeingProcessed ) )
                {
                sequenceOnPage++;
                }
            else
                {
                sequenceOnPage = 0;
                prevFileBeingProcessed = fileBeingProcessed;
                }
            // Used both for ads and quotations. Don't insert any code there specific to either.
            // We want to want to ensure each ad rotates at a given interval, but not all ads should
            // change on the same day. We want to make sure quotation at top and bottom of the page
            // change at the same time, but don't select the same quotation.
            // Use SHA-1 instead of CRC32 to get better scrambling.
            // We are not hashing the content of any page, just the file name.
            final MessageDigest digester1 = MessageDigest.getInstance( "SHA" );
            // throw in hashCode so all randoms won't change at once.
            // seed will change every changeIntervalInMillis. The value itself has no physical meaning.
            // if the changeInterval is greater than a day, we arrange things so that the change occurs at midnight UTC, and
            // roughly 1/n of them change on any given day, where n is the change interval in days.
            // if the changeInterval is greater than an hour, we arrange things so that the change occurs on the hour UTC,
            // roughly 1/n of them change on any given hour, where n in the change interval in hours.
            // rotateInterval is how often we change ANY ad/quote with this changeInterval.
            // changeIntervalInMillis is how often we change any given ad/quote.
            // It is shorter than changeInterval
            final long rotateIntervalInMillis = calcRotateInterval( changeIntervalInMillis );
            // some number 0..changeIntervalInMillis, on day or hour boundary.
            // Accelerate when we change the ad by some random multiple of the rotateInterval.
            final long accelerateChangeByMillis = ( ( fileBeingProcessed.hashCode() & Integer.MAX_VALUE )
                                                    % ( changeIntervalInMillis / rotateIntervalInMillis ) ) * rotateIntervalInMillis;
            // seed will change only once every changeIntervalMillis, but not all quotes/ads will change at once
            // because they have different accelerateChangeByMillis.
            final int seed = ( int ) ( ( System.currentTimeMillis() + accelerateChangeByMillis ) / changeIntervalInMillis );
            // hash 4-bytes of the seed in big-endian order.
            for ( int shift = 24; shift >= 0; shift -= 8 )
                {
                digester1.update( ( byte ) ( ( seed >>> shift ) & 0xff ) );
                }
            // hash in the file name we are expanding. webroot relative dir/file/ext
            digester1.update( fileBeingProcessed.getPath().getBytes( "UTF-8" ) );
            // hash in index on page.
            // only low bits will be occupied. Multiply by 41 to create greater distance in hash.
            digester1.update( ( byte ) ( sequenceOnPage * 41 ) );
            // extract scrambled digest
            // should be 20 bytes, 160 bits long.
            final byte[] digest = digester1.digest();
            assert digest.length == 20 : "SHA digest not 20 bytes long";
            // collapse 20 bytes down to 4 with CRC32 which does a better job of scrambling than Adler32
            // We want to avoid the same quote being chosen on the same page or nearby pages at the same time.
            CRC32 digester2 = new CRC32();
            digester2.update( digest );
            // Actual result is 32 bits long not 64.
            return ( int ) digester2.getValue();
            }
        catch ( UnsupportedEncodingException e )
            {
            throw new IllegalArgumentException( "Missing UTF-8 encoding support" );
            }
        catch ( NoSuchAlgorithmException e )
            {
            throw new IllegalArgumentException( "Missing SHA support" );
            }
        }

    /**
     * given a 32-bit hash/digest, returns an a selection number.
     *
     * @param hash          32 bits, possibly signed.
     * @param possibilities how many possible choices there are
     *
     * @return a repeatable number in the range 0..possibilities.-1, If possibilities is 0, returns -1.
     */
    @SuppressWarnings( { "WeakerAccess" } )
    static int getRandomSelectorIndexForHash( final int hash, final int possibilities )
        {
        if ( possibilities == 0 )
            {
            return -1;
            }
        // strip sign bit and take modulus
        return ( hash & Integer.MAX_VALUE ) % possibilities;
        }

    /**
     * given a 32-bit hash/digest, returns an a selection number.
     *
     * @param hash    32 bits, possibly signed.
     * @param weights weight for choices 0..n-1. Not necessarily normalised to 100.
     *
     * @return a repeatable number in the range 0..n-1 based on hash.
     */
    @SuppressWarnings( { "WeakerAccess" } )
    static int getWeightedSelectorIndexForHash( final int hash, final int... weights )
        {
        // we need to normalise the weights
        int sum1 = 0;
        for ( int weight : weights )
            {
            sum1 += weight;
            }
        // generate a randomised number 0..sum1-1
        final int cutoff = ( hash & Integer.MAX_VALUE ) % sum1;
        // if weights were 5, 10, 5, then sum is 20 and assignments
        // 0..4 -> 0
        // 5..14 -> 1
        // 15..19 -> 2
        // code works even if some weights are zero.
        int sum2 = 0;
        for ( int choice = 0; choice < weights.length; choice++ )
            {
            sum2 += weights[ choice ];
            if ( sum2 > cutoff )
                {
                return choice;
                }
            }
        assert false : "program should never get here";
        return weights.length - 1;
        }
    }