/*
 * Copyright (c) 2002-2009 "Neo Technology,"
 *     Network Engine for Objects in Lund AB [http://neotechnology.com]
 *
 * This file is part of Neo4j.
 * 
 * Neo4j is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package org.neo4j.kernel.impl.transaction;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import javax.transaction.xa.Xid;

// TODO: fixed sized logs (pre-initialize them)
// keep dangling records in memory for log switch
// batch disk forces
/**
 * This class is made public for testing purposes only, do not use.
 * <p>
 * The {@link TxManager} uses this class to keep a transaction log for
 * transaction recovery.
 * <p>
 */
public class TxLog
{
    private String name = null;
    private FileChannel fileChannel = null;
    private ByteBuffer buffer = null;
    private int recordCount = 0;

    public static final byte TX_START = 1;
    public static final byte BRANCH_ADD = 2;
    public static final byte MARK_COMMIT = 3;
    public static final byte TX_DONE = 4;

    /**
     * Initializes a transaction log using <CODE>filename</CODE>. If the file
     * isn't empty the position will be set to size of file so new records will
     * be appended.
     * 
     * @param fileName
     *            Filename of file to use
     * @throws IOException
     *             If unable to open file
     */
    public TxLog( String fileName ) throws IOException
    {
        if ( fileName == null )
        {
            throw new IllegalArgumentException( "Null filename" );
        }
        fileChannel = new RandomAccessFile( fileName, "rw" ).getChannel();
        fileChannel.position( fileChannel.size() );
        buffer = ByteBuffer.allocateDirect( 
            (3 + Xid.MAXGTRIDSIZE + Xid.MAXBQUALSIZE) * 1000 );
        this.name = fileName;
    }

    /**
     * Returns the name of the transaction log.
     */
    public String getName()
    {
        return name;
    }

    /**
     * Returns the number of records (one of TX_START,BRANCH_ADD,MARK_COMMIT or
     * TX_DONE) written since this instance was created or truncated.
     */
    public int getRecordCount()
    {
        return recordCount;
    }

    /**
     * Closes the file representing the transaction log.
     */
    public void close() throws IOException
    {
        fileChannel.close();
    }

    /**
     * Forces the log file (with metadata). Useful when switching log.
     */
    public void force() throws IOException
    {
        fileChannel.force( true );
    }

    /**
     * Truncates the file to zero size and sets the record count to zero.
     */
    public synchronized void truncate() throws IOException
    {
        fileChannel.position( 0 );
        fileChannel.truncate( 0 );
        recordCount = 0;
    }

    /**
     * Writes a <CODE>TX_START</CODE> record to the file.
     * 
     * @param globalId
     *            The global id of the new transaction
     * @throws IOException
     *             If unable to write
     */
    // tx_start(byte)|gid_length(byte)|globalId
    public synchronized void txStart( byte globalId[] ) throws IOException
    {
        if ( globalId == null )
        {
            throw new IllegalArgumentException( "Null parameter" );
        }
        buffer.clear();
        buffer.put( TX_START ).put( (byte) globalId.length ).put( globalId );
        buffer.flip();
        fileChannel.write( buffer );
        recordCount++;
    }

    /**
     * Writes a <CODE>BRANCH_ADD</CODE> record to the file.
     * 
     * @param globalId
     *            The global id of the transaction
     * @param branchId
     *            The branch id for the enlisted resource
     * @throws IOException
     *             If unable to write
     */
    // add_branch(byte)|gid_length(byte)|bid_length(byte)|globalId|branchId
    public synchronized void addBranch( byte globalId[], byte branchId[] )
        throws IOException
    {
        if ( globalId == null )
        {
            throw new IllegalArgumentException( "Null global id" );
        }
        if ( branchId == null )
        {
            throw new IllegalArgumentException( "Null branch id" );
        }
        buffer.clear();
        buffer.put( BRANCH_ADD ).put( (byte) globalId.length ).put(
            (byte) branchId.length ).put( globalId ).put( branchId );
        buffer.flip();
        fileChannel.write( buffer );
        recordCount++;
    }

    /**
     * Writes a <CODE>MARK_COMMIT</CODE> record to the file and forces the
     * file to disk.
     * 
     * @param globalId
     *            The global id of the transaction
     * @throws IOException
     *             If unable to write
     */
    // mark_committing(byte)|gid_length(byte)|globalId
    // forces
    public synchronized void markAsCommitting( byte globalId[] )
        throws IOException
    {
        if ( globalId == null )
        {
            throw new IllegalArgumentException( "Null parameter" );
        }
        buffer.clear();
        buffer.put( MARK_COMMIT ).put( (byte) globalId.length ).put( globalId );
        buffer.flip();
        fileChannel.write( buffer );
        fileChannel.force( false );
        recordCount++;
    }

    /**
     * Writes a <CODE>TX_DONE</CODE> record to the file.
     * 
     * @param globalId
     *            The global id of the transaction completed
     * @throws IOException
     *             If unable to write
     */
    // tx_done(byte)|gid_length(byte)|globalId
    public synchronized void txDone( byte globalId[] ) throws IOException
    {
        if ( globalId == null )
        {
            throw new IllegalArgumentException( "Null parameter" );
        }
        buffer.clear();
        buffer.put( TX_DONE ).put( (byte) globalId.length ).put( globalId );
        buffer.flip();
        fileChannel.write( buffer );
        recordCount++;
    }

    /**
     * Made public for testing only.
     * <p>
     * Wraps a transaction record in the tx log file.
     */
    public static class Record
    {
        private byte type = 0;
        private byte globalId[] = null;
        private byte branchId[] = null;
        private int seqNr = -1;

        Record( byte type, byte globalId[], byte branchId[], int seqNr )
        {
            if ( type < 1 || type > 4 )
            {
                throw new IllegalArgumentException( "Illegal type: " + type );
            }
            this.type = type;
            this.globalId = globalId;
            this.branchId = branchId;
            this.seqNr = seqNr;
        }

        public byte getType()
        {
            return type;
        }

        public byte[] getGlobalId()
        {
            return globalId;
        }

        public byte[] getBranchId()
        {
            return branchId;
        }

        public int getSequenceNumber()
        {
            return seqNr;
        }

        public String toString()
        {
            XidImpl xid = new XidImpl( globalId, branchId == null ? new byte[0]
                : branchId );
            return "TxLogRecord[" + type + "," + xid + "," + seqNr + "]";
        }
    }

    void writeRecord( Record record ) throws IOException
    {
        if ( record.getType() == TX_START )
        {
            txStart( record.getGlobalId() );
        }
        else if ( record.getType() == BRANCH_ADD )
        {
            addBranch( record.getGlobalId(), record.getBranchId() );
        }
        else if ( record.getType() == MARK_COMMIT )
        {
            markAsCommitting( record.getGlobalId() );
        }
        else
        {
            // TX_DONE should never be passed in here
            throw new IOException( "Illegal record type[" + record.getType()
                + "]" );
        }
    }

    /**
     * Returns an array of lists, each list contains dangling records
     * (transactions that han't been completed yet) grouped after global by
     * transaction id.
     */
    public synchronized Iterator<List<Record>> getDanglingRecords()
        throws IOException
    {
        fileChannel.position( 0 );
        buffer.clear();
        fileChannel.read( buffer );
        buffer.flip();
        // next record position
        long nextPosition = 0;
        // holds possible dangling records
        int seqNr = 0;
        Map<Xid,List<Record>> recordMap = new HashMap<Xid,List<Record>>();
        while ( buffer.hasRemaining() )
        {
            byte recordType = buffer.get();
            if ( recordType == TX_START )
            {
                if ( !buffer.hasRemaining() )
                {
                    break;
                }
                byte globalId[] = new byte[buffer.get()];
                if ( buffer.limit() - buffer.position() < globalId.length )
                {
                    break;
                }
                buffer.get( globalId );
                Xid xid = new XidImpl( globalId, new byte[0] );
                if ( recordMap.containsKey( xid ) )
                {
                    throw new IOException( "Tx start for same xid[" + xid
                        + "] found twice" );
                }
                List<Record> recordList = new LinkedList<Record>();
                recordList.add( new Record( recordType, globalId, null, 
                    seqNr++ ) );
                recordMap.put( xid, recordList );
                nextPosition += 2 + globalId.length;
            }
            else if ( recordType == BRANCH_ADD )
            {
                if ( buffer.limit() - buffer.position() < 2 )
                {
                    break;
                }
                byte globalId[] = new byte[buffer.get()];
                byte branchId[] = new byte[buffer.get()];
                if ( buffer.limit() - buffer.position() < 
                    globalId.length + branchId.length )
                {
                    break;
                }
                buffer.get( globalId );
                buffer.get( branchId );
                Xid xid = new XidImpl( globalId, new byte[0] );
                if ( !recordMap.containsKey( xid ) )
                {
                    throw new IOException( "Branch[" + new String( branchId )
                        + "] found for [" + xid
                        + "] but no record list found in map" );
                }
                recordMap.get( xid ).add(
                    new Record( recordType, globalId, branchId, seqNr++ ) );
                nextPosition += 3 + globalId.length + branchId.length;
            }
            else if ( recordType == MARK_COMMIT )
            {
                if ( !buffer.hasRemaining() )
                {
                    break;
                }
                byte globalId[] = new byte[buffer.get()];
                if ( buffer.limit() - buffer.position() < globalId.length )
                {
                    break;
                }
                buffer.get( globalId );
                Xid xid = new XidImpl( globalId, new byte[0] );
                if ( !recordMap.containsKey( xid ) )
                {
                    throw new IOException( "Commiting xid[" + xid
                        + "] mark found but no record list found in map" );
                }
                List<Record> recordList = recordMap.get( xid );
                recordList.add( new Record( recordType, globalId, null, 
                    seqNr++ ) );
                recordMap.put( xid, recordList );
                nextPosition += 2 + globalId.length;
            }
            else if ( recordType == TX_DONE )
            {
                if ( !buffer.hasRemaining() )
                {
                    break;
                }
                byte globalId[] = new byte[buffer.get()];
                if ( buffer.limit() - buffer.position() < globalId.length )
                {
                    break;
                }
                buffer.get( globalId );
                Xid xid = new XidImpl( globalId, new byte[0] );
                if ( !recordMap.containsKey( xid ) )
                {
                    throw new IOException( "Commiting xid[" + xid
                        + "] mark found but no record list found in map" );
                }
                recordMap.remove( xid );
                nextPosition += 2 + globalId.length;
            }
            else if ( recordType == 0 )
            {
                continue;
            }
            else
            {
                throw new IOException( "Unknown type: " + recordType );
            }
            if ( (buffer.limit() - buffer.position()) < 
                (3 + Xid.MAXGTRIDSIZE + Xid.MAXBQUALSIZE) )
            {
                // make sure we don't try to read non full entry
                buffer.clear();
                fileChannel.position( nextPosition );
                fileChannel.read( buffer );
                buffer.flip();
            }
        }
        return recordMap.values().iterator();
    }

    /**
     * Switches log file. Copies the dangling records in current log file to the
     * <CODE>newFile</CODE> and the makes the switch closing the old log file.
     * 
     * @param newFile
     *            The filename of the new file to switch to
     * @throws IOException
     *             If unable to switch log file
     */
    public synchronized void switchToLogFile( String newFile )
        throws IOException
    {
        if ( newFile == null )
        {
            throw new IllegalArgumentException( "Null filename" );
        }
        // copy all dangling records from current log to new log
        force();
        Iterator<List<Record>> itr = getDanglingRecords();
        close();
        List<Record> records = new ArrayList<Record>();
        while ( itr.hasNext() )
        {
            records.addAll( itr.next() );
        }
        Collections.sort( records, new Comparator<Record>()
        {
            public int compare( Record r1, Record r2 )
            {
                return r1.getSequenceNumber() - r2.getSequenceNumber();
            }
        } );
        Iterator<Record> recordItr = records.iterator();
        fileChannel = new RandomAccessFile( newFile, "rw" ).getChannel();
        fileChannel.position( fileChannel.size() );
        name = newFile;
        truncate();
        while ( recordItr.hasNext() )
        {
            Record record = recordItr.next();
            writeRecord( record );
        }
        force();
    }
}