package ru.bitel.bgbilling.modules.inet.dyn.device.snmp;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import ru.bitel.bgbilling.common.BGException;
import ru.bitel.bgbilling.kernel.event.EventProcessor;
import ru.bitel.bgbilling.modules.inet.server.tariff.level.DeviceLevelEvent;
import ru.bitel.bgbilling.server.util.Setup;
import ru.bitel.common.ParameterMap;
import ru.bitel.common.Utils;
import ru.bitel.oss.systems.inventory.resource.common.bean.Device;
import ru.bitel.oss.systems.inventory.resource.common.bean.DeviceType;
import uk.co.westhawk.snmp.stack.AsnObjectId;

/**
 * Проверка загруженности канала и установка "уровня" для <b>устройства</b>.
 * См. ветка "Уровень" тарифного плана.<br/><br/>
 * 
 * <b>snmp.deviceLevel.loadCounterOid</b> - oid счетчика, с которого будем считывать загрузку канала<br/>
 * <b>snmp.deviceLevel.window</b> - время в секундах, за которое будет происходить расчет средней загрузки, например, от 10 до 30 минут<br/>
 * <b>snmp.deviceLevel.deviceIds</b> - устройства, для которых будут переключаться уровни<br/><br/> 
 * <b>manage.uptime.pause</b> - секунды, желательно указывать небольшим, например, от 2 до 5 минут<br/><br/> 
 * <b>snmp.deviceLevel.map.1.min</b> - <br/>
 * <b>snmp.deviceLevel.map.1.max</b> - Пределы загрузки, для которой будет устанавливаться уровень 1. Пределы уровней желательно указывать с "нахлестом" друг на друга,
 * чтобы при переключении на новый уровень не происходило быстрое переключение обратно на исходный при отскоке загрузки канала<br/>
 *  
 * @author amir
 */
public class LevelDeviceManager
	extends SnmpDeviceManager
{
	private static final Logger logger = LogManager.getLogger();

	private int moduleId;
	private int deviceId;

	private long lastUptime = -1L;
	private BigInteger lastCounterValue = null;

	private long[] loadCounterOid;

	private AverageCalculator calculator;

	private Set<Integer> linkDeviceIds;

	private static final BigInteger TWO_COMPL_REF = BigInteger.ONE.shiftLeft( 64 );
	private static final BigInteger UNSIGNED_LONG_MAX_VALUE = unsignedLong( -1L );

	private static BigInteger unsignedLong( final long value )
	{
		BigInteger result = BigInteger.valueOf( value );
		if ( result.compareTo( BigInteger.ZERO ) < 0 )
		{
			result = result.add( TWO_COMPL_REF );
		}

		return result;
	}

	static class Level
	{
		private int level;

		private BigInteger min;
		private BigInteger max;

		public Level( int level, BigInteger min, BigInteger max )
		{
			this.level = level;
			this.min = min;
			this.max = max;
		}

		public boolean isCoincide( final BigInteger value )
		{
			if ( min.compareTo( value ) <= 0 && (BigInteger.ZERO.compareTo( max ) == 0 || max.compareTo( value ) >= 0) )
			{
				return true;
			}

			return false;
		}
	}

	private Level currentLevel;

	private List<Level> levelList;

	@Override
	public Object init( Setup setup, int moduleId, Device<?, ?> device, DeviceType deviceType, ParameterMap deviceConfig )
	{
		super.init( setup, moduleId, device, deviceType, deviceConfig );

		this.moduleId = moduleId;
		this.deviceId = device.getId();

		this.loadCounterOid = new AsnObjectId( deviceConfig.get( "snmp.deviceLevel.loadCounterOid", "1.3.6.1.2.1.31.1.1.1.6.42" ) ).getOid();

		int levelWindow = deviceConfig.getInt( "snmp.deviceLevel.window", (int)TimeUnit.MINUTES.toSeconds( 30 ) );

		long uptimePause = deviceConfig.getLong( "manage.uptime.pause.millis", deviceConfig.getLong( "manage.uptime.pause", 5 * 60 ) * 1000 );
		long minLevelWindow = Math.min( uptimePause / 1000 * 30, TimeUnit.MINUTES.toSeconds( 90 ) );

		if ( levelWindow < minLevelWindow )
		{
			levelWindow = (int)minLevelWindow;
		}

		logger.info( "Device level window is: " + levelWindow );

		linkDeviceIds = Utils.toIntegerSet( deviceConfig.get( "snmp.deviceLevel.deviceIds", null ) );
		linkDeviceIds.remove( 0 );

		if ( linkDeviceIds.size() == 0 )
		{
			linkDeviceIds.add( deviceId );
		}

		this.levelList = new ArrayList<>();

		for ( Map.Entry<Integer, ParameterMap> e : deviceConfig.subIndexed( "snmp.deviceLevel.map." ).entrySet() )
		{
			if ( e.getKey() == null || e.getKey() < 0 )
			{
				continue;
			}

			int level = e.getKey();
			ParameterMap map = e.getValue();

			BigInteger min = BigInteger.valueOf( map.getLong( "min", 0 ) );
			BigInteger max = BigInteger.valueOf( map.getLong( "max", 0 ) );

			if ( min.compareTo( BigInteger.ZERO ) == 0 && max.compareTo( BigInteger.ZERO ) == 0 )
			{
				continue;
			}

			logger.info( "Add level " + level + " (" + min + "-" + max + ")" );

			this.levelList.add( new Level( level, min, max ) );
		}

		logger.info( "Set level window to " + levelWindow + " sec" );

		this.calculator = AverageCalculator.newInstance( levelWindow, TimeUnit.SECONDS );

		return null;
	}

	@Override
	public Object uptime()
		throws Exception
	{
		Long uptime = (Long)super.uptime();

		if ( uptime != null )
		{
			try
			{
				checkDeviceLoad( uptime );
			}
			catch( Exception ex )
			{
				logger.error( ex.getMessage(), ex );
			}

			this.lastUptime = uptime;
		}

		return uptime;
	}

	private void checkDeviceLoad( long uptime )
		throws BGException
	{
		long millis = System.currentTimeMillis();

		Long counterValueLong = snmpClient.get( loadCounterOid, -1, Long.class );
		if ( counterValueLong == null )
		{
			logger.warn( "loadCounterOid return null" );
			return;
		}

		BigInteger counterValue = unsignedLong( counterValueLong );

		logger.info( "Counter value for deviceId:" + deviceId + " is: " + counterValue );

		if ( lastCounterValue == null )
		{
			lastCounterValue = counterValue;
			return;
		}

		BigInteger value;

		if ( lastCounterValue.compareTo( counterValue ) > 0 )
		{
			if ( lastUptime <= 0 )
			{
				lastCounterValue = counterValue;
				return;
			}

			// перезагрузка
			if ( lastUptime >= uptime )
			{
				logger.info( "Detect reboot (counter was set to zero): lastCounterValue=" + lastCounterValue + " and newCounterValue=" + counterValue + ", lastUptime=" + lastUptime
							 + " and newUptime=" + uptime );

				value = counterValue;
			}
			// переполнение
			else
			{
				logger.info( "Detect overflow of counter: lastCounterValue=" + lastCounterValue + " and newCounterValue=" + counterValue + " (reboot not detected by uptime)" );

				value = UNSIGNED_LONG_MAX_VALUE.subtract( lastCounterValue ).add( counterValue );
			}
		}
		else
		{
			value = counterValue.subtract( lastCounterValue );
		}

		lastCounterValue = counterValue;

		logger.info( "Delta value for deviceId:" + deviceId + " is: " + value );

		final BigInteger average = calculator.addAndGetAverage( millis, value );

		logger.info( "Current average for deviceId:" + deviceId + " is: " + average );

		if ( average == null )
		{

		}
		else if ( currentLevel != null && currentLevel.isCoincide( average ) )
		{
			logger.info( "Level not changed for deviceId:" + deviceId + " - " + currentLevel.level );
		}
		else
		{
			for ( Level l : levelList )
			{
				if ( l.isCoincide( average ) )
				{
					currentLevel = l;
					logger.info( "Level changed for deviceId:" + deviceId + " to: " + l.level );

					for ( Integer deviceId : linkDeviceIds )
					{
						EventProcessor.getInstance().publish( new DeviceLevelEvent( moduleId, deviceId, currentLevel.level ) );
					}

					break;
				}
			}
		}
	}

	static class BigIntegerArray
	{
		protected final int window;
		protected final BigInteger[] array;

		protected long beginX = -1;
		protected long x;

		protected int count = 0;

		public BigIntegerArray( int window )
		{
			this.window = window;

			this.array = new BigInteger[this.window];
		}

		private void clear( final BigInteger[] array, final int window, final int pos, int length )
		{
			for ( int i = pos; i >= 0 && (length--) > 0; i++ )
			{
				if ( array[i] != null )
				{
					count--;
				}

				array[i] = null;
			}

			for ( int i = window - 1; (length--) > 0 && i > pos; i++ )
			{
				if ( array[i] != null )
				{
					count--;
				}

				array[i] = null;
			}
		}

		private void update0( final BigInteger[] array, final int window, final long beginX, final long x )
		{
			// разница между "началом" и текущим
			long delta = x - beginX;

			if ( delta >= window )
			{
				delta = delta - window;

				if ( delta >= window )
				{
					clear( array, window, 0, window );
				}
				else
				{
					clear( array, window, (int)(beginX) % window, (int)delta + 1 );
				}

				this.beginX = x;
			}
			else
			{
				final int index = (int)((this.x + 1) % window);
				final int index2 = (int)((x) % window);
				final int len = index2 - index + 1;

				clear( array, window, index, len );
			}
		}

		synchronized void update( final long x )
		{
			if ( this.beginX == -1 )
			{
				this.beginX = x;
			}

			if ( this.x == x )
			{
				return;
			}

			update0( array, window, beginX, x );

			/*int idx = (int)(x % this.window);
			if ( this.array[idx] != null )
			{
				System.out.println( this.x );
				System.out.println( x );
				System.out.println( idx );

				update0( array, window, beginX, x );
			}*/

			this.x = x;
			//this.beginX = x;
		}

		synchronized BigInteger[] updateNew( final long x )
		{
			BigInteger[] array = Arrays.copyOf( this.array, this.array.length );

			update0( array, window, beginX, x );

			return array;
		}

		synchronized void add( final long x, final BigInteger value )
		{
			update( x );

			int idx = (int)(x % this.window);
			if ( this.array[idx] == null )
			{
				count++;
				this.array[idx] = value;
			}
			else
			{
				this.array[idx] = this.array[idx].add( value );
			}
		}
	}

	static class AverageCalculator
		extends BigIntegerArray
	{
		private long periodLength;

		private AverageCalculator( int window, long periodLength )
		{
			super( window );

			this.periodLength = periodLength;
		}

		static AverageCalculator newInstance( long period, TimeUnit unit )
		{
			final TimeUnit lessUnit = getLessTimeUnit( unit );

			int window = (int)lessUnit.convert( period, unit ) + 1;

			long periodLength = TimeUnit.MILLISECONDS.convert( 1, lessUnit );

			return new AverageCalculator( window, periodLength );
		}

		private static TimeUnit getLessTimeUnit( TimeUnit unit )
		{
			switch( unit )
			{
				case DAYS:
					return TimeUnit.HOURS;

				case HOURS:
					return TimeUnit.MINUTES;

				default:
					return TimeUnit.SECONDS;
			}
		}

		public BigInteger addAndGetAverage( long nowMillis, BigInteger value )
		{
			// id текущего периода
			final long currentPeriod = nowMillis / periodLength;

			super.add( currentPeriod, value );

			// если значений меньше - то лучше ничего не возвращать,
			// пока не накопим данных
			if ( (window >= 3000 && super.count < 120)
				|| (window < 3000 && super.count < 60) )
			{
				logger.info( "Not enough records to calculate average" );
				return null;
			}

			logger.info( "Count: " + count );

			int beginX = (int)(this.beginX) % window;

			int count = 0;
			int fieldCount = 0;

			BigInteger sum = BigInteger.ZERO;

			for ( int i = beginX; i < window && count < this.count; i++ )
			{
				BigInteger val = array[i];
				if ( val != null )
				{
					sum = sum.add( val );
					count++;
				}

				fieldCount++;
			}

			for ( int i = 0; i < beginX && count < this.count; i++ )
			{
				BigInteger val = array[i];
				if ( val != null )
				{
					sum = sum.add( val );
					count++;
				}

				fieldCount++;
			}

			logger.info( "Field count: " + fieldCount );
			//logger.info( "begin: " + beginX + ", x: " + x );
			//logger.info( String.valueOf( Arrays.asList( super.array ) ) );

			return sum.divide( BigInteger.valueOf( fieldCount ) ).multiply( BigInteger.valueOf( 8 ) );
		}
	}
}
