/*
 * Copyright 2013 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.bitcoinj.protocols.channels;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Multimap;
import com.google.common.collect.MultimapBuilder;
import org.bitcoinj.core.*;
import org.bitcoinj.crypto.TransactionSignature;
import org.bitcoinj.protocols.channels.IPaymentChannelClient.ClientChannelProperties;
import org.bitcoinj.script.Script;
import org.bitcoinj.script.ScriptBuilder;
import org.bitcoinj.wallet.AllowUnconfirmedCoinSelector;
import org.bitcoinj.wallet.SendRequest;
import org.bitcoinj.wallet.Wallet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.spongycastle.crypto.params.KeyParameter;

import javax.annotation.Nullable;
import java.math.BigInteger;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;

/**
 * Version 2 of the payment channel state machine - uses CLTV opcode transactions
 * instead of multisig transactions.
 */
public class PaymentChannelV2ClientState extends PaymentChannelClientState {
    private static final Logger log = LoggerFactory.getLogger(PaymentChannelV1ClientState.class);

    // How much value (in satoshis) is locked up into the channel.
    private final Coin totalValue;
    // When the channel will automatically settle in favor of the client, if the server halts before protocol termination
    // specified in terms of block timestamps (so it can off real time by a few hours).
    private final long expiryTime;

    // The refund is a time locked transaction that spends all the money of the channel back to the client.
    // Unlike in V1 this refund isn't signed by the server - we only have to sign it ourselves.
    @VisibleForTesting Transaction refundTx;
    private Coin refundFees;

    // The multi-sig contract locks the value of the channel up such that the agreement of both parties is required
    // to spend it.
    private Transaction contract;

    PaymentChannelV2ClientState(StoredClientChannel storedClientChannel, Wallet wallet) throws VerificationException {
        super(storedClientChannel, wallet);
        // The PaymentChannelClientConnection handles storedClientChannel.active and ensures we aren't resuming channels
        this.contract = checkNotNull(storedClientChannel.contract);
        this.expiryTime = storedClientChannel.expiryTime;
        this.totalValue = contract.getOutput(0).getValue();
        this.valueToMe = checkNotNull(storedClientChannel.valueToMe);
        this.refundTx = checkNotNull(storedClientChannel.refund);
        this.refundFees = checkNotNull(storedClientChannel.refundFees);
        stateMachine.transition(State.READY);
        initWalletListeners();
    }

    public PaymentChannelV2ClientState(Wallet wallet, ECKey myKey, ECKey serverMultisigKey, Coin value, long expiryTimeInSeconds) throws VerificationException {
        super(wallet, myKey, serverMultisigKey, value, expiryTimeInSeconds);
        checkArgument(value.signum() > 0);
        initWalletListeners();
        this.valueToMe = this.totalValue = checkNotNull(value);
        this.expiryTime = expiryTimeInSeconds;
        stateMachine.transition(State.NEW);
    }

    @Override
    protected Multimap<State, State> getStateTransitions() {
        Multimap<State, State> result = MultimapBuilder.enumKeys(State.class).arrayListValues().build();
        result.put(State.UNINITIALISED, State.NEW);
        result.put(State.UNINITIALISED, State.READY);
        result.put(State.NEW, State.SAVE_STATE_IN_WALLET);
        result.put(State.SAVE_STATE_IN_WALLET, State.PROVIDE_MULTISIG_CONTRACT_TO_SERVER);
        result.put(State.PROVIDE_MULTISIG_CONTRACT_TO_SERVER, State.READY);
        result.put(State.READY, State.EXPIRED);
        result.put(State.READY, State.CLOSED);
        return result;
    }

    @Override
    public int getMajorVersion() {
        return 2;
    }

    @Override
    public synchronized void initiate(@Nullable KeyParameter userKey, ClientChannelProperties clientChannelProperties) throws ValueOutOfRangeException, InsufficientMoneyException {
        final NetworkParameters params = wallet.getParams();
        Transaction template = new Transaction(params);
        // There is also probably a change output, but we don't bother shuffling them as it's obvious from the
        // format which one is the change. If we start obfuscating the change output better in future this may
        // be worth revisiting.
        Script redeemScript =
                ScriptBuilder.createCLTVPaymentChannelOutput(BigInteger.valueOf(expiryTime), myKey, serverKey);
        TransactionOutput transactionOutput = template.addOutput(totalValue,
                ScriptBuilder.createP2SHOutputScript(redeemScript));
        if (transactionOutput.isDust())
            throw new ValueOutOfRangeException("totalValue too small to use");
        SendRequest req = SendRequest.forTx(template);
        req.coinSelector = AllowUnconfirmedCoinSelector.get();
        req.shuffleOutputs = false;   // TODO: Fix things so shuffling is usable.
        req = clientChannelProperties.modifyContractSendRequest(req);
        if (userKey != null) req.aesKey = userKey;
        wallet.completeTx(req);
        Coin multisigFee = req.tx.getFee();
        contract = req.tx;

        // Build a refund transaction that protects us in the case of a bad server that's just trying to cause havoc
        // by locking up peoples money (perhaps as a precursor to a ransom attempt). We time lock it because the
        // CheckLockTimeVerify opcode requires a lock time to be specified and the input to have a non-final sequence
        // number (so that the lock time is not disabled).
        refundTx = new Transaction(params);
        // by using this sequence value, we avoid extra full replace-by-fee and relative lock time processing.
        refundTx.addInput(contract.getOutput(0)).setSequenceNumber(TransactionInput.NO_SEQUENCE - 1L);
        refundTx.setLockTime(expiryTime);
        if (Context.get().isEnsureMinRequiredFee()) {
            // Must pay min fee.
            final Coin valueAfterFee = totalValue.subtract(Transaction.REFERENCE_DEFAULT_MIN_TX_FEE);
            if (Transaction.MIN_NONDUST_OUTPUT.compareTo(valueAfterFee) > 0)
                throw new ValueOutOfRangeException("totalValue too small to use");
            refundTx.addOutput(valueAfterFee, myKey.toAddress(params));
            refundFees = multisigFee.add(Transaction.REFERENCE_DEFAULT_MIN_TX_FEE);
        } else {
            refundTx.addOutput(totalValue, myKey.toAddress(params));
            refundFees = multisigFee;
        }

        TransactionSignature refundSignature =
                refundTx.calculateSignature(0, myKey.maybeDecrypt(userKey),
                        getSignedScript(), Transaction.SigHash.ALL, false);
        refundTx.getInput(0).setScriptSig(ScriptBuilder.createCLTVPaymentChannelP2SHRefund(refundSignature, redeemScript));

        refundTx.getConfidence().setSource(TransactionConfidence.Source.SELF);
        log.info("initiated channel with contract {}", contract.getHashAsString());
        stateMachine.transition(State.SAVE_STATE_IN_WALLET);
        // Client should now call getIncompleteRefundTransaction() and send it to the server.
    }

    @Override
    protected synchronized Coin getValueToMe() {
        return valueToMe;
    }

    protected long getExpiryTime() {
        return expiryTime;
    }

    @Override
    public synchronized Transaction getContract() {
        checkState(contract != null);
        if (stateMachine.getState() == State.PROVIDE_MULTISIG_CONTRACT_TO_SERVER) {
            stateMachine.transition(State.READY);
        }
        return contract;
    }

    @Override
    protected synchronized Transaction getContractInternal() {
        return contract;
    }

    protected synchronized Script getContractScript() {
        return contract.getOutput(0).getScriptPubKey();
    }

    @Override
    protected Script getSignedScript() {
        return ScriptBuilder.createCLTVPaymentChannelOutput(BigInteger.valueOf(expiryTime), myKey, serverKey);
    }

    @Override
    public synchronized Coin getRefundTxFees() {
        checkState(getState().compareTo(State.NEW) > 0);
        return refundFees;
    }

    @VisibleForTesting Transaction getRefundTransaction() {
        return refundTx;
    }

    @Override
    @VisibleForTesting synchronized void doStoreChannelInWallet(Sha256Hash id) {
        StoredPaymentChannelClientStates channels = (StoredPaymentChannelClientStates)
                wallet.getExtensions().get(StoredPaymentChannelClientStates.EXTENSION_ID);
        checkNotNull(channels, "You have not added the StoredPaymentChannelClientStates extension to the wallet.");
        checkState(channels.getChannel(id, contract.getHash()) == null);
        storedChannel = new StoredClientChannel(getMajorVersion(), id, contract, refundTx, myKey, serverKey, valueToMe, refundFees, expiryTime, true);
        channels.putChannel(storedChannel);
    }

    @Override
    public Coin getTotalValue() {
        return totalValue;
    }
}
