package ru.bitel.bgbilling.modules.tv.dyn.tv24h;

import jakarta.annotation.Resource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import ru.bitel.bgbilling.apps.tv.access.TvAccess;
import ru.bitel.bgbilling.apps.tv.access.om.AbstractOrderEvent;
import ru.bitel.bgbilling.apps.tv.access.om.AccountOrderEvent;
import ru.bitel.bgbilling.apps.tv.access.om.ProductEntry;
import ru.bitel.bgbilling.apps.tv.access.om.ProductOrderEvent;
import ru.bitel.bgbilling.common.BGException;
import ru.bitel.bgbilling.kernel.admin.errorlog.common.bean.AlarmErrorMessage;
import ru.bitel.bgbilling.kernel.admin.errorlog.server.AlarmSender;
import ru.bitel.bgbilling.kernel.container.managed.ServerContext;
import ru.bitel.bgbilling.kernel.contract.api.server.bean.ContractDao;
import ru.bitel.bgbilling.kernel.contract.balance.server.bean.BalanceDao;
import ru.bitel.bgbilling.kernel.contract.runtime.ContractRuntime;
import ru.bitel.bgbilling.kernel.contract.runtime.ContractRuntimeMap;
import ru.bitel.bgbilling.kernel.event.EventProcessor;
import ru.bitel.bgbilling.kernel.module.common.bean.User;
import ru.bitel.bgbilling.modules.tv.common.bean.TvAccount;
import ru.bitel.bgbilling.modules.tv.common.bean.TvDevice;
import ru.bitel.bgbilling.modules.tv.common.bean.TvDeviceType;
import ru.bitel.bgbilling.modules.tv.common.event.TvAccountModifiedEvent;
import ru.bitel.bgbilling.modules.tv.common.om.OrderManager;
import ru.bitel.bgbilling.modules.tv.dyn.JsonClient;
import ru.bitel.bgbilling.modules.tv.dyn.JsonClient.JsonClientException;
import ru.bitel.bgbilling.modules.tv.dyn.JsonClient.Method;
import ru.bitel.bgbilling.modules.tv.dyn.TvDynUtils;
import ru.bitel.bgbilling.modules.tv.dyn.tv24h.Tv24hConf.SubscriptionMode;
import ru.bitel.bgbilling.modules.tv.server.bean.TvAccountDao;
import ru.bitel.bgbilling.modules.tv.server.runtime.ProductSpecRuntime;
import ru.bitel.common.ParameterMap;
import ru.bitel.common.TimeUtils;
import ru.bitel.common.Utils;
import ru.bitel.oss.kernel.entity.common.bean.EntityAttrPhone;
import ru.bitel.oss.systems.inventory.product.common.bean.Product;
import ru.bitel.oss.systems.inventory.product.common.bean.ProductSpec;
import ru.bitel.oss.systems.inventory.product.common.service.ProductService;

import java.io.IOException;
import java.math.BigDecimal;
import java.net.MalformedURLException;
import java.net.URI;
import java.text.ParseException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

public class Tv24hOrderManager
    implements OrderManager
{
    private static final Logger logger = LogManager.getLogger();

    // private static final String dateTimePattern =
    // private static final String dateTimePattern =
    // "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'";
    private static final String dateTimePattern2 = "yyyy-MM-dd'T'HH:mm:ss'Z'";

    // private final DateTimeFormatter dateTimeFormatter =
    // DateTimeFormatter.ofPattern( dateTimePattern ).withZone( ZoneId.of( "UTC"
    // ) );
    private final DateTimeFormatter dateTimeFormatter2 = DateTimeFormatter.ofPattern( dateTimePattern2 ).withZone( ZoneId.of( "UTC" ) );

    private String token;

    private JsonClient jsonClient;

    private Tv24hConf conf;

    private int moduleId;
    private TvAccountDao tvAccountDao;

    @Resource(name = "access")
    private TvAccess access;

    @Override
    public Object init( ServerContext ctx, int moduleId, TvDevice tvDevice, TvDeviceType tvDeviceType, ParameterMap config )
        throws Exception
    {
        this.moduleId = moduleId;
        this.token = config.get( "om.tv24h.token", Utils.maskBlank( tvDevice.getSecret(), tvDevice.getPassword() ) );
        this.conf = ctx.getSetup().getConfig( moduleId, Tv24hConf.class );
        return null;
    }

    @Override
    public Object destroy()
        throws Exception
    {
        return null;
    }

    @Override
    public Object connect( ServerContext ctx )
        throws Exception
    {
        jsonClient = new JsonClient( URI.create( conf.providerURL ).toURL(), null, null );
        tvAccountDao = new TvAccountDao( ctx.getConnection(), moduleId );

        return null;
    }

    @Override
    public Object disconnect( ServerContext ctx )
        throws Exception
    {
        if ( jsonClient != null )
        {
            jsonClient.disconnect();
        }

        return null;
    }

    private ZonedDateTime parseTime( String time )
    {
        try
        {
            return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse( time, ZonedDateTime::from );

        }
        catch( DateTimeParseException ex )
        {
            try
            {
                return dateTimeFormatter2.parse( time, ZonedDateTime::from );
            }
            catch( DateTimeParseException ex2 )
            {
                try
                {
                    return dateTimeFormatter2.parse( time, ZonedDateTime::from );
                }
                catch( DateTimeParseException ex3 )
                {
                    throw ex3;
                }
            }
        }
    }

    private JSONObject invoke( Method method, String resource, Object obj )
        throws IOException, BGException, JSONException
    {
        Map<String, String> requestOptions = new HashMap<>();
        requestOptions.put( "Content-Type", "application/json; charset=UTF-8" );
        requestOptions.put( "Accept", "application/json" );

        resource += !resource.contains( "?" ) ? "?token=" + token : "&token=" + token;

        try
        {
            return jsonClient.invoke( method, requestOptions, resource, null, obj );
        }
        catch( JsonClientException ex )
        {
            logger.error( "INVOKE Error metod=>" + method.toString() + ", resource=>" + resource + ", respose=>" + ex.getData() );
            throw ex;
        }
    }

    private JSONArray invokeAndGetArray( Method method, String resource, Object obj )
        throws IOException, BGException, JSONException
    {
        Map<String, String> requestOptions = new HashMap<>();
        requestOptions.put( "Content-Type", "application/json" );
        requestOptions.put( "Accept", "application/json" );

        if ( !resource.contains( "?" ) )
        {
            resource += "?token=" + token;
        }
        else
        {
            resource += "&token=" + token;
        }
        try
        {
            return jsonClient.invokeAndGetArray( method, requestOptions, resource, null, obj );
        }
        catch( JsonClientException ex )
        {
            String req= obj==null?"null":obj.toString();
            logger.error( "INVOKE Error metod=>" + method.toString() + ", resource=>" + resource + ", req=>"
                          + req + ", respose=>" + ex.getData() );
            throw ex;
        }
    }

    @Override
    public Object accountCreate( AccountOrderEvent e, ServerContext ctx )
        throws Exception
    {
        logger.info( "accountCreate" );

        accountModify( e, ctx );

        return null;
    }

    private int thisAccountExists(TvAccount tvAccount)
        throws IOException, BGException
    {
        JSONArray users = invokeAndGetArray( Method.get, "/v2/users?phone=" + tvAccount.getLogin(), null );
        if ( users == null || users.length() == 0 )
            return -1;

        JSONObject user = users.getJSONObject( 0 );
        int userId = user.optInt( "id", -1 );
        logger.info( "Ответ на запрос о наличии пользователя: {}, userId = {}", user.toString(), userId );
        return userId > 0 ? userId : -1;
    }

    private JSONObject updateProviderUID( int userId, int accountId )
        throws IOException, BGException
    {
        JSONObject userUpdate = new JSONObject();
        userUpdate.put( "provider_uid", "bgb" + accountId );
        JSONObject resp = invoke( Method.patch, "/v2/users/" + userId, userUpdate );
        logger.info( "Ответ на обновление: " + resp.toString() );
        return resp;
    }

    @Override
    public Object accountModify( final AccountOrderEvent accountOrderEvent, final ServerContext ctx )
        throws Exception
    {
        Object result = null;

        logger.info( "accountModify" );

        //при создании аккаунта возможно бывает такая ситуация, что с таким номером телефона абонент уже создан в системе 24тв
        // и он закреплён за оператором, но у нас его всё ещё не было и создают в клиенте,
        // и в таком случае в ответе от тв24 получаем ошибку
        //поэтому сначала проверяем, что такого номера нет. Но если он есть, то обновляем у него provider_uid и только после этого вызываем accountModify
        TvAccount account = accountOrderEvent.getNewTvAccount();
        int userIdInTV24;
        if ( (userIdInTV24 = thisAccountExists( account )) > 0 )
        {
            logger.info( "Такой аккаунт уже существует в ТВ24. Обновляем у него provider_uid на = bgs" + account.getId() );
            JSONObject user = updateProviderUID( userIdInTV24, account.getId() );
            account.setDeviceAccountId( user.optString( "id" ) );
        }
        else
        {
            logger.info( "Такой аккаунт не найден в ТВ24, создаём новый" );
        }

        // id из 24h может быть прописан в поле Идентификатор
        String userId = accountOrderEvent.getNewTvAccount().getDeviceAccountId();
        if ( Utils.isBlankString( userId ) )
        {
            userId = accountOrderEvent.getTvAccountRuntime().getTvAccount().getIdentifier();
            if ( Utils.isBlankString( userId ) && accountOrderEvent.getOldTvAccount() != null )
            {
                userId = accountOrderEvent.getOldTvAccount().getDeviceAccountId();
            }
        }

        logger.info( "userId: " + userId );

        try ( ContractDao contractDao = new ContractDao( ctx.getConnection(), User.USER_SERVER ) )
        {
            final int contractId = accountOrderEvent.getContractId();
            String email = null;
            if ( conf.paramEmailId > 0 )
            {
                email = TvDynUtils.getEmail( contractDao, contractId, conf.paramEmailId );
                logger.info( "email: " + email );
            }
    
            String phone = account.getLogin();
            if ( Utils.isBlankString( phone ) && conf.paramPhoneId > 0 )
            {
                Optional<EntityAttrPhone> attrPhone = contractDao.getContractParameterPhone( contractId, conf.paramPhoneId );
                if ( attrPhone.isPresent() )
                {
                    phone = attrPhone.get().getContactList().get( 0 ).getPhone();
                    logger.info( "phone: " + phone );
                }
            }
    
            if ( Utils.isBlankString( phone ) )
            {
                logger.info( "phone is empty string" );
            }
    
            if ( Utils.isBlankString( email ) )
            {
                logger.info( "email is empty string" );
            }
    
            // добавляем фамилию и имя из параметров договора, если были указаны в конфиге
            String firstName = conf.paramFirstNameId > 0 ? contractDao.optContractParameterTextAsString( contractId, conf.paramFirstNameId ).orElse( "" ) : "";
            String lastName = conf.paramLastNameId > 0 ? contractDao.optContractParameterTextAsString( contractId, conf.paramLastNameId ).orElse( "" ) : "";

            // уже существует?
            if ( Utils.notBlankString( userId ) )
            {
                // обработка создания со стороны MW
                accountOrderEvent.getEntry().setDeviceAccountId( userId );
    
                // обработка изменения аккаунта из биллинга
                if ( accountOrderEvent.getOldTvAccount() != null )
                {
                    result = accountModify0( userId, accountOrderEvent, email, phone, firstName, lastName );
                }
            }
            else if ( accountOrderEvent.getOldTvAccount() == null || Utils.isBlankString( userId ) )
            {
                result = accountCreate0( accountOrderEvent, email, phone, firstName, lastName );
            }
    
            if ( !conf.agentMode && !Boolean.FALSE.equals( result ) )
            {
                ContractRuntime contractRuntime = ContractRuntimeMap.getInstance().getContractRuntime( ctx.getConnection(), contractId );
                BigDecimal balance = getBalance( ctx, contractRuntime );
                balanceUpdate( conf.providerURL, token, contractId, userId, balance );
            }
        }
        return null;
    }

    /**
     * Создание (или обновление, если пользователь с таким телефоном уже есть).
     * 
     * @param e
     * @param email
     * @param phone
     * @return
     * @throws IOException
     * @throws BGException
     */
    private Object accountCreate0( final AccountOrderEvent e, final String email, final String phone, String firstName, String lastName )
        throws IOException, BGException
    {
        try
        {
            accountCreate1( e, email, phone, firstName, lastName );
        }
        catch( JsonClientException ex )
        {
            if ( ex.getResponseCode() != 400 )
            {
                throw ex;
            }

            logger.info( "Found error" );

            try
            {
                JSONObject message = new JSONObject( ex.getData() );

                if ( message.get( "detail" ).toString().contains( "User with this username already exists." ) )
                {
                    logger.info( "Skip username already exists error" );

                    // TODO: обработка
                    return false;
                }
                else
                {
                    throw ex;
                }
            }
            catch( Exception ex2 )
            {
                logger.info( ex2.getMessage(), ex2 );
                throw ex;
            }
        }
        catch( IllegalStateException ex )
        {
            logger.info( "Skip username already exists error" );

            // TODO: обработка
            return false;
        }

        return null;
    }

    private void accountCreate1( final AccountOrderEvent accountOrderEvent, final String email, final String phone, String firstName, String lastName )
        throws IOException, BGException
    {
        logger.info( "create account" );

        JSONObject user = new JSONObject();
        user.put( "username", accountOrderEvent.getNewTvAccount().getLogin() );
        user.put( "password", accountOrderEvent.getNewTvAccount().getPassword() );
        user.put( "provider_uid", "bgb" + accountOrderEvent.getTvAccountId() );
        user.put( "is_provider_free", conf.providerFree );

        if ( Utils.notBlankString( phone ) )
        {
            user.put( "phone", phone );
        }

        if ( Utils.notBlankString( email ) )
        {
            user.put( "email", email );
        }

        if ( Utils.notBlankString( firstName ) )
        {
            user.put( "first_name", firstName );
        }

        if ( Utils.notBlankString( lastName ) )
        {
            user.put( "last_name", lastName );
        }

        String userId = null;

        try
        {
            user = invoke( Method.post, "/v2/users", user );
            int id = user.optInt( "id", -1 );
            userId = id > 0 ? String.valueOf( id ) : null;
        }
        catch( JsonClientException ex )
        {
            if ( ex.getResponseCode() == 400 )
            {
                try
                {
                    JSONObject message = new JSONObject( ex.getData() );

                    // номер телефона уже заведен - по нему можем искать
                    String detail = message.get( "detail" ).toString();
                    if ( detail.contains( "User with this phone already exists." ) || detail.contains( "Пользователь с таким именем уже существует." ) )
                    {
                        JSONArray users = invokeAndGetArray( Method.get, "/v2/users?phone=" + phone, null );
                        for ( int i = 0, size = users.length(); i < size; i++ )
                        {
                            JSONObject existingUser = users.getJSONObject( 0 );
                            userId = String.valueOf( existingUser.getInt( "id" ) );

                            logger.info( "modify already existing account" );

                            invoke( Method.patch, "/v2/users/" + userId, user );
                        }
                    }
                    else if ( detail.contains( "does not match" ) )
                    {
                        String error = "Не верный формат электронного адреса! Detail: " + detail;
                        logger.error( error );
                        throw new BGException( error );
                    }
                    else
                    {
                        throw ex;
                    }
                }
                catch( Exception ex2 )
                {
                    logger.info( ex2.getMessage(), ex2 );
                    throw ex;
                }
            }
        }

        if ( userId == null )
        {
            throw new IllegalStateException( "userId is null" );
        }

        tvAccountWasCreated( accountOrderEvent, userId );

        if ( checkNotHasBillingAccountOnTV24h( userId, accountOrderEvent.getContractId() ) )
        {
            boolean result = createBillingAccountOnTV24h( userId );
            logger.info( result ? "Биллинговый аккаунт был успешно создан"
                                : "Не удалось создать биллинговый аккаунт" );
        }
    }

    private void tvAccountWasCreated( AccountOrderEvent e, String userId )
    {
        e.getEntry().setDeviceAccountId( userId );

        try
        {
            TvAccount newTvAccount = tvAccountDao.get( e.getNewTvAccount().getId() );
            if ( newTvAccount != null )
            {
                TvAccount oldTvAccount = newTvAccount.clone();

                newTvAccount.setIdentifier( userId );
                tvAccountDao.update( newTvAccount );

                EventProcessor.getInstance().publish( new TvAccountModifiedEvent( moduleId, e.getContractId(), 0, oldTvAccount, newTvAccount ) );
            }
        }
        catch( Exception ex )
        {
            logger.error( ex.getMessage(), ex );
        }
    }

    /**
     * Перед созданием "биллингового счета" в ТВ24 необходимо проверить, что его ещё не создано, иначе создастся ещё один
     *
     * Метод проверяет, что НЕТ счета
     *
     * @return true - если биллингового счёта НЕТ, false - если есть
     */
    private boolean checkNotHasBillingAccountOnTV24h( String userId, int contractId )
    {
        if ( Utils.isBlankString( userId ) || contractId <= 0 )
            return true;

        boolean result = true;
        try
        {
            logger.info( "Проверка есть ли биллинговый аккаунт для UserID = " + userId + ", contractId = " + contractId );
            JSONArray response = invokeAndGetArray( Method.get, "/v2/users/" + userId + "/accounts", null  );
            result = (response == null || response.length() == 0);
        }
        catch( BGException | IOException e )
        {
            logger.error( "Ошибка при получении списка биллинговых аккаунтов: " + e );
        }
        return result;
    }

    //

    /**
     * Создание "биллингового счёта"
     * @param userId - ID пользователя в TV24h
     * @return true - при успешном создании счета, false - если не удалось создать
     */
    private boolean createBillingAccountOnTV24h( String userId )
    {
        if ( Utils.isBlankString( userId ) )
            return false;

        try
        {
            logger.info( "Create billing account on TV24h" );
            logger.info( "Отправляем запрос на создание аккаунта в ТВ24. UserId=" + userId );
            JSONObject response = invoke( Method.post, "/v2/users/" + userId + "/accounts", null );
            if ( logger.isDebugEnabled() )
            {
                logger.debug("Ответ на запрос создания аккаунта в биллинге: " + response.toString());
            }
            return true;
        }
        catch( Exception ex )
        {
            logger.error( ex );
            return false;
        }
    }

    private Object accountModify0( String userId, AccountOrderEvent e, String email, String phone, String firstName, String lastName )
        throws IOException, BGException
    {
        try
        {
            accountModify1( userId, e, email, phone, firstName, lastName );
        }
        catch( JsonClientException ex )
        {
            if (  ex.getResponseCode()  / 100 != 4  )
            {
                throw ex;
            }

            logger.info( "Found error" );

            try
            {
                JSONObject message = new JSONObject( ex.getData() );
                logger.info( "message ok" );
                String errorMsg = message.get( "detail" ).toString();
                logger.info( "errorMsg: " + errorMsg );
                if ( errorMsg.contains( "User with this username already exists." ) )
                {
                    logger.info( "Skip username already exists error" );

                    // TODO: обработка
                    return false;
                }
                else if ( errorMsg.toLowerCase().contains( "не найден" ) )
                {
                    logger.info( "Skip username user not exists." );
                    String text = "Ошибка Tv24hOrderManager пользователь не заведен в админке  [" + userId + "]";
                    AlarmSender.sendAlarm( new AlarmErrorMessage( "Tv24hOrderManager.userNotFound",
                                                                  text, text ), System.currentTimeMillis() );
                    return false;
                }
                else
                {
                    throw ex;
                }
            }
            catch( Exception ex2 )
            {
                
                logger.info( ex2.getMessage(), ex2 );
                throw ex;
            }
        }

        return null;
    }

    private void accountModify1( String userId, AccountOrderEvent e, String email, String phone, String firstName, String lastName )
        throws IOException, BGException
    {
        logger.info( "modify account" );

        JSONObject user = new JSONObject();

        if ( !e.getOldTvAccount().getLogin().equals( e.getNewTvAccount().getLogin() ) )
        {
            user.put( "username", e.getNewTvAccount().getLogin() );
        }

        if ( (e.getOldTvAccount().getPassword() != null && e.getNewTvAccount() != null) && !e.getOldTvAccount().getPassword().equals( e.getNewTvAccount().getPassword() ) )
        {
            user.put( "password", e.getNewTvAccount().getPassword() );
        }

        if ( Utils.notBlankString( phone ) )
        {
            user.put( "phone", phone );
        }

        if ( Utils.notBlankString( email ) )
        {
            user.put( "email", email );
        }

        if ( Utils.notBlankString( firstName ) )
        {
            user.put( "first_name", firstName );
        }

        if ( Utils.notBlankString( lastName ) )
        {
            user.put( "last_name", lastName );
        }

        user.put( "provider_uid", "bgb" + e.getTvAccountId() );

        if ( user.length() > 0 )
        {
            user.put( "is_provider_free", conf.providerFree );
            invoke( Method.patch, "/v2/users/" + userId, user );
        }
        else
        {
            logger.info( "No changes included" );
        }
    }

    @Override
    public Object accountRemove( AccountOrderEvent e, ServerContext ctx )
        throws Exception
    {
        logger.info( "accountRemove" );

        return null;
    }

    @Override
    public Object accountStateModify( AccountOrderEvent e, ServerContext ctx )
        throws Exception
    {
        logger.info( "accountStateModify" );

        if ( !conf.enableSubscriptionPause )
            return null;

        if ( e.getOldTvAccount() == null || Utils.isBlankString( e.getOldTvAccount().getDeviceAccountId()) )
        {
            return accountModify( e, ctx );
        }

        String userId = e.getOldTvAccount().getDeviceAccountId();

        if ( e.getNewState() == TvAccount.STATE_ENABLE )
        {
            Set<String> productSetToEnable = e.getFullProductEntryListToEnable()
                                              .stream()
                                              .map( v -> v.getProductSpec().getIdentifier()).collect( Collectors.toSet() );
            if (!productSetToEnable.isEmpty())
            {
                // текущие активные подписки на паузе
                JSONArray currentSubscription = invokeAndGetArray(Method.get, "/v2/users/" + userId + "/subscriptions?types=paused", null);
                for (int i = 0, size = currentSubscription.length(); i < size; i++)
                {
                    final JSONObject subscription = currentSubscription.getJSONObject( i );
                    final JSONObject packet = subscription.getJSONObject("packet");
                    if ( productSetToEnable.contains( packet.optString("id") ) )
                    {
                        JSONObject currentPause = findCurrentPause( subscription );
                        if ( currentPause != null )
                        {
                            invoke( Method.delete, "/v2/users/" + userId + "/subscriptions/" + subscription.optString("id") + "/pauses/" + currentPause.get("id"), null );
                        }
                    }
                }
            }
        }
        else
        {
            // текущие активные подписки (без учета стоящих на паузе)
            JSONArray currentSubscription = invokeAndGetArray( Method.get, "/v2/users/" + userId + "/subscriptions?types=active", null );
            for (int i = 0, size = currentSubscription.length(); i < size; i++)
            {
                final JSONObject subscription = currentSubscription.getJSONObject( i );
                final JSONObject packet = subscription.getJSONObject("packet");
                ProductSpec productSpec = getProductSpecByIdentifier( packet.optString( "id") );
                //Если продукт отсутствует в справочнике биллинга - игнорируем, т.к. возможно это промо продукт
                if ( productSpec == null )
                {
                    continue;
                }

                subscriptionPause0( userId, subscription.optString("id") );
            }
        }

        return null;
    }

    private void subscriptionPause( final ProductOrderEvent e, final String deviceProductId )
        throws IOException, BGException
    {
        logger.info( "subscriptionPause" );

        if ( Utils.notBlankString( deviceProductId ) )
        {
            String userId = getUserId( e );
            if ( userId == null )
            {
                // нет идентификатора, это ошибка
                return;
            }

            long tv24hSubscriptionId = Utils.parseLong( deviceProductId.split( "-" )[0] );
            subscriptionPause0( userId, String.valueOf( tv24hSubscriptionId ) );
        }
        else
        {
            logger.info( "deviceProductId is null. skip pause" );
        }
    }

    private void subscriptionPause0( String userId, String subscriptionId )
        throws IOException, BGException
    {
        try
        {
            JSONObject pause = new JSONObject();
            pause.put( "start_at", dateTimeFormatter2.format( ZonedDateTime.now(ZoneId.of("UTC") ) ) );

            invoke( Method.post, "/v2/users/" + userId + "/subscriptions/" + subscriptionId + "/pauses", pause );
        }
        catch ( JsonClientException ex )
        {
            if ( Utils.notBlankString( ex.getData() ) )
            {
                JSONObject dataJson;
                try
                {
                    dataJson = new JSONObject( ex.getData() );
                    if ( dataJson.optInt("status_code") != 404 )
                    {
                        throw ex;
                    }
                    logger.info("Subscriptions " + subscriptionId + " not found. Skip...");
                }
                catch (Exception ex2)
                {
                    throw ex;
                }
            }
        }
    }

    private ProductSpec getProductSpecByIdentifier( String identifier )
    {
        ProductSpecRuntime productSpecRuntime = access.productSpecRuntimeMap.list()
                                                                            .stream()
                                                                            .filter( v -> v.productSpec.getIdentifier().equals( identifier)
                                                                                          && (v.productSpec.getDateTo() == null
                                                                                              || TimeUtils.dateBeforeOrEq( new Date(), v.productSpec.getDateTo())))
                                                                            .findAny()
                                                                            .orElse( null);
        if (productSpecRuntime == null)
        {
            return null;
        }

        return productSpecRuntime.getProductSpec();
    }

    @Override
    public Object accountOptionsModify( AbstractOrderEvent e, ServerContext ctx )
        throws Exception
    {
        logger.info( "accountOptionsModify" );

        return null;
    }

    @Override
    public Object productsModify( ProductOrderEvent productOrderEvent, ServerContext serverContext )
        throws Exception
    {
        logger.info( "productsModify" );

        for ( ProductEntry productEntry : productOrderEvent.getProductEntryList() )
        {
            logger.info( "OLD PRODUCT: {}; OLD STATE: {}", productEntry.getOldProduct(), productEntry.getOldState() );
            logger.info( "NEW PRODUCT: {}; NEW STATE: {}", productEntry.getNewProduct(), productEntry.getNewState() );

            if ( conf.agentMode )
            {
                if ( productEntry.getNewState() == Product.STATE_DISABLED )
                {
                    Product product = serverContext.getService( ProductService.class, 0 ).productGet( productOrderEvent.getContractId(), productEntry.getNewProduct().getId() );
                    // деактивировали вручную из биллинга
                    if ( product != null && product.getDeactivationTime() != null )
                    {
                        subscriptionDeactivate( productOrderEvent, productEntry, product.getDeviceProductId() );
                    }
                }
                // добавление нового пакета из биллинга
                else if ( productEntry.getNewState() == Product.STATE_ENABLED && productEntry.getOldState() == Product.STATE_REMOVED )
                {
                    subscriptionActivateAgent( productOrderEvent, productEntry );
                }
            }
            else
            {
                if ( productEntry.getNewState() == Product.STATE_DISABLED || productEntry.getNewState() == Product.STATE_REMOVED )
                {
                    logger.info( "conf.subscriptionMode = {}", conf.subscriptionMode );
                    if ( conf.subscriptionMode == SubscriptionMode.renew && productEntry.getNewState() != Product.STATE_REMOVED
                         && (productEntry.getNewProduct().getTimeTo() == null
                             || productEntry.getNewProduct().getTimeTo().after( new Date() )) )
                    {
                        Product product = productEntry.getNewProduct();
                        subscriptionPause( productOrderEvent, product.getDeviceProductId() );
                    }
                    else
                    {
                        subscriptionDeactivate( productOrderEvent, productEntry, productEntry.getOldProduct().getDeviceProductId() );
                        logger.info( "subscriptionDeactivate!" );
                    }
                }
                else
                {
                    subscriptionActivate( productOrderEvent, productEntry );
                }
            }
        }

        return null;
    }

    /**
     * В агентском режиме просто активируем и указываем renew=true, 24h сам
     * списывает, продлевает подписку и оповещает об изменениях через webhook.
     * 
     * @param e
     * @param pe
     * @throws IOException
     * @throws BGException
     * @throws JsonClientException
     */
    private void subscriptionActivateAgent( final ProductOrderEvent e, final ProductEntry pe )
        throws IOException,
        BGException,
        JsonClientException
    {
        String productIdentifier = pe.getProductSpec().getIdentifier();

        if ( Utils.isBlankString( productIdentifier ) )
        {
            logger.warn( "Can't activate productSpec:" + pe.getProductSpec().getId() + " with empty identifier!" );
            return;
        }

        JSONObject subscription = new JSONObject();
        subscription.put( "packet_id", Utils.parseInt( productIdentifier ) );
        subscription.put( "renew", true );

        String userId = getUserId( e );
        if (userId == null)
        {
            //нету идентификатора, это ошибка
            return;
        }

        try
        {
            JSONArray subscriptions = invokeAndGetArray( JsonClient.Method.post, "/v2/users/" + userId
                                                                                 + "/subscriptions", new JSONArray( Collections.singleton( subscription ) ) );

            subscription = subscriptions.getJSONObject( 0 );

            logger.info( "subscription id = " + subscription.get( "id" ) );

            pe.getNewProduct().setDeviceProductId( String.valueOf( subscription.get( "id" ) ) );
        }
        catch( JsonClientException ex )
        {
            try
            {
                JSONObject message = new JSONObject( ex.getData() );

                String detailString = message.get( "detail" ).toString();
                if ( detailString.contains( "You already subscribe on this packet." ) )
                {
                    logger.warn( "You already subscribe on this packet" );
                }
                else if ( detailString.contains( "You Need billing account for subscription." ) || detailString.contains( "Вам необходим биллинговый счет для подписки" ) )
                {
                    logger.warn( "Reposnse error: " + detailString );
                    if ( checkNotHasBillingAccountOnTV24h( userId, e.getContractId() ) )
                    {
                        createBillingAccountOnTV24h( userId );
                    }
                }
                else
                {
                    throw ex;
                }
            }
            catch( Exception ex2 )
            {
                logger.info( ex2.getMessage(), ex2 );
                throw ex;
            }
        }
    }

    private void subscriptionActivate( final ProductOrderEvent e, final ProductEntry pe )
        throws IOException,
        BGException,
        JsonClientException,
        JSONException,
        ParseException
    {
        try
        {
            subscriptionActivate0( e, pe );
        }
        catch( JsonClientException ex )
        {
            try
            {
                JSONObject message = new JSONObject( ex.getData() );

                String detailString = message.get( "detail" ).toString();
                if ( detailString.contains( "You already subscribe on this packet." ) )
                {
                    logger.warn( "You already subscribe on this packet" );
                }
                else if ( detailString.contains( "You Need billing account for subscription." ) || detailString.contains( "Вам необходим биллинговый счет для подписки" ) )
                {
                    logger.warn( "Reposnse error: " + detailString );
                    String userId = getUserId( e );
                    if ( checkNotHasBillingAccountOnTV24h( userId, e.getContractId() ) )
                    {
                        createBillingAccountOnTV24h( getUserId( e ) );
                    }
                }
                else
                {
                    throw ex;
                }
            }
            catch( Exception ex2 )
            {
                logger.info( ex2.getMessage(), ex2 );
                throw ex;
            }
        }
    }

    /**
     * В неагентском режиме всегда сами продлеваем периоды подписки.
     * 
     * @param e
     * @param productEntry
     */
    private void subscriptionActivate0( final ProductOrderEvent e, final ProductEntry productEntry )
        throws IOException, BGException, JSONException
    {
        String userId = getUserId( e );
        if ( userId == null )
        {
            //нету идентификатора, это ошибка
            logger.error( "Cannot define userId from ProductOrderEvent = " + e );
            return;
        }

        String productIdentifier = productEntry.getProductSpec().getIdentifier();
        if ( Utils.isBlankString( productIdentifier ) )
        {
            logger.error( "Can't activate productSpec:" + productEntry.getProductSpec().getId() + " with empty identifier!" );
            return;
        }

        int packetId = Utils.parseInt( productIdentifier );

        final ZonedDateTime now = ZonedDateTime.now( ZoneId.of( "UTC" ) );

        // текущая подписка
        JSONObject currentSubscription = null;
        // время окончания последней подписки на MW
        ZonedDateTime lastSubscriptionEnd = null;
        // текущие подписки
        JSONArray subscriptions = invokeAndGetArray( JsonClient.Method.get, "/v2/users/" + userId
                                                                            + "/subscriptions?packet_ids="
                                                                            + packetId, null );
        // ищем время окончания текущей подписки
        for ( int i = 0, size = subscriptions.length(); i < size; i++ )
        {
            final JSONObject subscription = subscriptions.getJSONObject( i );
            final JSONObject packet = subscription.getJSONObject( "packet" );

            if ( packet.getInt( "id" ) != packetId )
            {
                continue;
            }

            boolean renew = subscription.getBoolean( "renew" ); // на пакете включено автопродление?

            if ( renew ) // должна быть только одна подписка с renew==true
            {
                currentSubscription = subscription;
                break;
            }
            else
            {
                ZonedDateTime endAt = parseTime( subscription.getString( "end_at" ) );
                if ( lastSubscriptionEnd == null || endAt.isAfter( lastSubscriptionEnd ) )
                {
                    lastSubscriptionEnd = endAt;

                    if ( now.isBefore( endAt ) )
                    {
                        currentSubscription = subscription;
                    }
                }
            }
        }

        if ( currentSubscription != null )
        {
            logger.info( "Found active subscription " + currentSubscription.get( "id" ) );
        }

        // в режиме renew при необходимости отключить - ставим на паузу
        // при необходимости включить - пытаемся снять с паузы, если не находим
        // подписку - создаем новую
        if ( conf.subscriptionMode == SubscriptionMode.renew && currentSubscription != null )
        {
            final String tv24hSubscriptionId = String.valueOf( currentSubscription.get( "id" ) );

            if ( !currentSubscription.getBoolean( "renew" ) ) // необходимо переключить на renew, мы будем оперировать паузами
            {
                currentSubscription = enableRenew( userId, currentSubscription, tv24hSubscriptionId );
            }

            if ( currentSubscription != null )
            {
                if ( currentSubscription.getBoolean( "is_paused" ) ) // если подписка на паузе - нам надо ее включить
                {
                    JSONObject currentPause = findCurrentPause( currentSubscription );

                    if ( currentPause != null ) // отменяем паузу
                    {
                        invoke( JsonClient.Method.delete, "/v2/users/" + userId + "/subscriptions/"
                                                          + tv24hSubscriptionId + "/pauses/" + currentPause.get( "id" ), null );
                        productEntry.getNewProduct().setDeviceProductId( String.valueOf( currentSubscription.get( "id" ) ) );
                        return;
                    }
                    else // если вдруг паузу не нашли - отменяем подписку, начнем новую
                    {
                        subscriptionDeactivate( e, productEntry, tv24hSubscriptionId );
                        lastSubscriptionEnd = null;
                    }
                }
                else
                {
                    logger.info( "Subscription already active" );

                    productEntry.getNewProduct().setDeviceProductId( String.valueOf( currentSubscription.get( "id" ) ) );
                    return;
                }
            }
        }

        Date dateFrom = productEntry.getNewProduct().getSubscriptionTimeFrom();
        ZonedDateTime subscriptionTimeFrom = dateFrom != null ? ZonedDateTime.ofInstant( dateFrom.toInstant(), ZoneId.of( "UTC" ) ) : null;
        
        Date dateTo = productEntry.getNewProduct().getSubscriptionTimeTo();
        ZonedDateTime subscriptionTimeTo = dateTo != null ? ZonedDateTime.ofInstant( dateTo.toInstant(), ZoneId.of( "UTC" ) ) : null;
        
        if ( dateFrom == null || dateTo == null )
        {
            return;
        }

        // устанавливаем время начала новой подписки не раньше чем время окончания предыдущей
        if ( lastSubscriptionEnd != null )
        {
            lastSubscriptionEnd = lastSubscriptionEnd.withNano( 0 ).plusSeconds( 1 );
            if ( lastSubscriptionEnd.isAfter( subscriptionTimeFrom ) )
            {
                subscriptionTimeFrom = lastSubscriptionEnd;
            }
        }

        subscriptionTimeTo.plusHours( 3 );

        JSONObject subscription = new JSONObject();
        subscription.put( "packet_id", Utils.parseInt( productIdentifier ) );
        // в режиме rerew подписка продлится сама
        subscription.put( "renew", conf.subscriptionMode == SubscriptionMode.renew );

        subscription.put( "start_at", dateTimeFormatter2.format( subscriptionTimeFrom ) );
        subscription.put( "end_at", dateTimeFormatter2.format( subscriptionTimeTo ) );

        subscriptions = createNewSubscription(userId, subscription, e.getContractId());
        if ( subscriptions != null )
        {
            subscription = subscriptions.getJSONObject( 0 );
            logger.info( "subscription id = " + subscription.get( "id" ) );

            productEntry.getNewProduct().setDeviceProductId( String.valueOf( subscription.get( "id" ) ) );
        }
        else
        {
            logger.error( "Cannot create new subscription. Subscriptions is null!" );
        }
    }

    private JSONArray createNewSubscription(String userId, JSONObject subscription, int contractId)
    {
        try
        {
            try
            {
                // создаем новую подписку
                logger.warn( "СОЗДАЁМ НОВУЮ ПОДПИСКУ" );
                return invokeAndGetArray( JsonClient.Method.post, "/v2/users/" + userId
                                                                  + "/subscriptions", new JSONArray( Collections.singleton( subscription ) ) );
            }
            catch( JsonClientException ex )
            {
                logger.error( "ЗАШЛИ В ОБРАБОТКУ JsonClientException" );
                JSONObject message = new JSONObject( ex.getData() );
                String detailString = message.get( "detail" ).toString();
                if ( detailString.contains( "You Need billing account for subscription." ) || detailString.contains( "Вам необходим биллинговый счет для подписки" ) )
                {
                    logger.warn( "Reposnse error: " + detailString );
                    if ( checkNotHasBillingAccountOnTV24h( userId, contractId ) )
                    {
                        createBillingAccountOnTV24h( userId );
                        return invokeAndGetArray( JsonClient.Method.post, "/v2/users/" + userId
                                                                          + "/subscriptions", new JSONArray( Collections.singleton( subscription ) ) );
                    }
                }
                logger.error( ex );
                return null;
            }
        }
        catch( IOException | BGException e )
        {
            logger.error( "ERROR BUT SKIP. Cannot create new subscription." );
            return null;
        }
    }

    private String getUserId( final ProductOrderEvent e )
    {
        String userId = e.getTvAccountRuntime().getTvAccount().getDeviceAccountId();
        if ( Utils.isEmptyString( userId ) )
        {
            TvAccount acc = e.getTvAccountRuntime().getTvAccount();
            String eMsg = "Ошибка Tv24hOrderManager не заполнен идентификатор id=> " + acc.getId() + ", login=> "
                          + acc.getLogin();
            logger.error( "ERROR=>"+eMsg );
            AlarmSender.sendAlarm( new AlarmErrorMessage( "Tv24hOrderManager.userId",
                                                          "Ошибка Tv24hOrderManager не заполнен идентификатор ["
                                                                                      + acc.getId() + "]",
                                                          eMsg ), System.currentTimeMillis() );
        }
        return userId;
    }

    /**
     * Включение renew для указанной подписки. Если включение renew не удалось -
     * возвращает null. Иначе - currentSubscription.
     * 
     * @param userId
     * @param currentSubscription
     * @param tv24hSubscriptionId
     * @return
     * @throws IOException
     * @throws BGException
     * @throws JsonClientException
     */
    private JSONObject enableRenew( String userId, JSONObject currentSubscription, final String tv24hSubscriptionId )
        throws IOException,
        BGException,
        JsonClientException
    {
        JSONArray subscriptions;
        JSONObject subscription = new JSONObject();
        subscription.put( "renew", true );

        try
        {
            // обновляем на renew
            subscriptions = invokeAndGetArray( JsonClient.Method.patch, "/v2/users/" + userId + "/subscriptions/"
                                                                        + tv24hSubscriptionId, subscription );
            subscription = subscriptions.getJSONObject( 0 );
        }
        catch( JsonClientException ex )
        {
            if ( ex.getResponseCode() != 400 )
            {
                throw ex;
            }

            logger.info( "Found error" );

            try
            {
                JSONObject message = new JSONObject( ex.getData() );

                if ( message.get( "detail" ).toString().contains( "Недоступный метод для текущей подписки." ) )
                {
                    logger.info( "Already closed subsciption. Possibly time not synchronized" );

                    currentSubscription = null;
                }
                else
                {
                    throw ex;
                }
            }
            catch( Exception ex2 )
            {
                logger.info( ex2.getMessage(), ex2 );
                throw ex;
            }
        }
        return currentSubscription;
    }

    private JSONObject findCurrentPause( JSONObject currentSubscription )
    {
        JSONObject result = null;

        JSONArray pauses = currentSubscription.getJSONArray( "pauses" );

        for ( int i = 0, size = pauses.length(); i < size; i++ )
        {
            final JSONObject pause = pauses.getJSONObject( i );

            if ( Utils.isBlankString( pause.optString( "end_at" ) ) ) // ищем паузу с бесконечным периодом
            {
                result = pause;
            }
        }

        return result;
    }

    private void subscriptionDeactivate( final ProductOrderEvent e, final ProductEntry productEntry, String deviceProductId )
        throws IOException, BGException
    {
        logger.info( "subscriptionDeactivate" );

        if ( Utils.notBlankString( deviceProductId ) )
        {
            String userId = getUserId( e );
            if ( userId == null )
            {
                //нету идентификатора, это ошибка
                return;
            }
            subscriptionDeactivate0( userId, productEntry, deviceProductId );
        }
        else
        {
            logger.info( "deviceProductId is null. skip deactivation" );
        }
    }

    private void subscriptionDeactivate0( final String userId, ProductEntry productEntry, String deviceProductId )
        throws IOException, BGException
    {
        try
        {
            invoke( Method.delete, "/v2/users/" + userId + "/subscriptions/" + deviceProductId, null );
        }
        catch (JsonClientException ex)
        {
            if (ex.getResponseCode() / 100 != 4)
            {
                throw ex;
            }

            logger.info( "Found error" );

            try
            {
                JSONObject message = new JSONObject( ex.getData() );
                String errorMsg = message.get( "detail" ).toString();
                logger.info( "errorMsg: " + errorMsg );
                if ( errorMsg.contains("Не найдено") )
                {
                    logger.info("Skip user not exists.");
                    String text = "Ошибка Tv24hOrderManager подписки не в админке [userId: " + userId + "; subscription: " + deviceProductId + "] ";
                    AlarmSender.sendAlarm(new AlarmErrorMessage("Tv24hOrderManager.subscriptionsNotFound", text, text), System.currentTimeMillis());
                }
                else if ( errorMsg.contains("Страница не найдена") )
                {
                    logger.debug( "Поиск подписки у которой мог смениться ID" );
                    JSONArray currentSubscription = invokeAndGetArray( Method.get, "/v2/users/" + userId + "/subscriptions?types=active", null );
                    for (int i = 0, size = currentSubscription.length(); i < size; i++)
                    {
                        final JSONObject subscription = currentSubscription.getJSONObject( i );
                        final JSONObject packet = subscription.getJSONObject("packet" );
                        ProductSpec productSpec = getProductSpecByIdentifier( packet.optString("id") );
                        if ( productSpec != null && productSpec.getId() == productEntry.getNewProduct().getProductSpecId() )
                        {
                            logger.info("Пробуем обновить ID подписки у текущего продукта");
                            //Не может быть больше 1 активной подписки на один и тот же продукт
                            productEntry.getNewProduct().setDeviceProductId( String.valueOf(subscription.get("id") ) );
                            throw ex;
                        }
                    }
                }
                else
                {
                    throw ex;
                }
            }
            catch ( Exception ex2 )
            {
                logger.info(ex2.getMessage(), ex2);
                throw ex;
            }
        }
    }

    /**
     * Обновление баланса в 24h.
     * 
     * @param token
     * @param contractId
     *            ID договора
     * @param userId
     *            ID в 24h
     * @param balance
     *            баланс договора
     * @throws MalformedURLException
     * @throws Exception
     */
    public static void balanceUpdate( String providerURL, String token, int contractId, String userId, BigDecimal balance )
        throws MalformedURLException, Exception
    {
        JSONObject req = new JSONObject()
        	.put( "id", contractId )
        	.put( "amount", Utils.formatCost( balance ) );

        JsonClient jsonClient = new JsonClient( URI.create( providerURL ).toURL(), null, null );

        Map<String, String> requestOptions = new HashMap<>();
        requestOptions.put( "Content-Type", "application/json" );
        requestOptions.put( "Accept", "application/json" );

        try
        {
            jsonClient.invoke( JsonClient.Method.post, requestOptions, "/v2/users/" + userId
                                                                       + "/provider/account?token="
                                                                       + token, null, req );
        }
        catch( JsonClientException ex )
        {
            logger.error( "INVOKE Error method=>post,resource=>" + "/v2/users/" + userId + "/provider/account?token="
                          + token + ",res=>" + req.toString() + ", response=>" + ex.getData() );
        }
        catch( Exception ex )
        {
            logger.error( ex.getMessage(), ex );
        }

        jsonClient.disconnect();
    }

    /**
     * Получение текущего баланса договора.
     * 
     * @param context
     * @param contractRuntime
     * @return
     * @throws BGException
     */
    static BigDecimal getBalance( final ServerContext context, final ContractRuntime contractRuntime )
        throws BGException
    {
        final LocalDate date = LocalDate.now();

        try (BalanceDao balanceDao = new BalanceDao( context.getConnection() ))
        {
            BigDecimal balance;

            if ( contractRuntime.getSuperContractId() > 0 )
            {
                balance = balanceDao.getBalance( contractRuntime.getSuperContractId(), date.getYear(), date.getMonthValue() );
            }
            else
            {
                balance = balanceDao.getBalance( contractRuntime.contractId, date.getYear(), date.getMonthValue() );
            }

            return balance;
        }
        catch( Exception ex )
        {
            throw new BGException( ex );
        }
    }
}
