<?php
/**
 * FIT Parser
 * 
 * $Id$
 * 
 * @author gERD Schaufelberger <gerd@php-tools.net>
 * @author Daniel Jahnke <daniel.jahnke@web.de>
 * @package FIT
 * @subpackage Parser
 * @license LGPL http://www.gnu.org/copyleft/lesser.html
 */

/**
 * FIT Parser
 * 
 * @version 0.1.1
 * @package FIT
 * @subpackage Fixture
 */
class Testing_FIT_Parser
{
   /**
    * This is where I keep the table data
    * @var array
    */
    private $_fitTables = array();
    
   /**
    * Current table the parser  is working on
    * @var int
    */
    private $_fitTableCurrent = 0;

   /**
    * Current table's row the parser  is working on
    * @var int
    */
    private $_fitRowCurrent = 0;

   /**
    * Current table's row's colum the parser  is working on
    * @var int
    */
    private $_fitColCurrent = 0;

   /**
    * CData stack used while parsing tables
    * @var array
    */
    private $_cData = array();

   /**
    * tags currently parsing
    * @var array
    */
    private $_tags = array();

   /**
    * current depth
    * @var int current depth
    */
    private $_fitDepth = 0;

   /**
    * known tags to parse
    * @var array
    */
    protected $_fitTags = array( 'table', 'tr', 'td' );
    
   /**
    * count nodes
    *
    * @param int $table index of table
    * @param int $row index of table's row
    * @return mixed node value or null if not exists
    */
    public function countChildNodes( $table = null, $row = null )
    {
        // count tables
        if( $table === null ) {
            //echo '<pre><br> ' . print_r( count( $this->_fitTables ) , true) ; echo '</pre>';
            return count( $this->_fitTables );
        }
        
        // table does not exist
        if( !isset( $this->_fitTables[$table] ) ) {
            return null;
        }

        if( $row === null ) {
            return count( $this->_fitTables[$table]['children'] );
        }
        
        // row does not exist
        if( !isset( $this->_fitTables[$table]['children'][$row] ) ) {
            return null;
        }
        
        return count( $this->_fitTables[$table]['children'][$row]['children'] );
    }

   /**
    * receive node value from parsed table
    *
    * @param string $field node's name (tag, before, after, children)
    * @param int $table index of table
    * @param int $row index of table's row
    * @param int $column index of table's row's columns
    * @return mixed node value or null if not exists
    */
    public function &getNodeValue( $field, $table, $row = null, $column = null )
    {
        $failed = null;
        
        // select table
        if( !isset( $this->_fitTables[$table] ) ) {
            return $failed;
        }
        $current =& $this->_fitTables[$table];
        
        // select row
        if( $row !== null ) {
            if( !isset( $current['children'][$row] ) ) {
                return $failed;
            }                        
            $current =& $current['children'][$row];
            
            // select column
            if( $column !== null ) {
                if( !isset( $current['children'][$column] ) ) {
                    return $failed;
                }
                $current =& $current['children'][$column];
            }
        }
        
        if( !isset( $current[$field] ) ) {
            return $failed;
        }
        
        return $current[$field];
    }

   /**
    * set node value from parsed table
    *
    * @param string $field node's name (tag, before, after, children, cData)
    * @param mixed $value node's new value
    * @param int $table index of table
    * @param int $row index of table's row
    * @param int $column index of table's row's columns
    * @return bool true on success
    */
    public function setNodeValue( $field, $value, $table, $row = null, $column = null )
    {
        $node   =& $this->getNodeValue( $field, $table, $row, $column );
        $node   = $value;
        return true;
    }
    
   /**
    * Serialize current tables to string
    *
    * This undoes the Parse function.
    * 
    * @return string $html tables
    */
    public function serialize() 
    {
        $html   =   '';
        for( $i = 0; $i < count( $this->_fitTables ); ++$i ) {
            $html   .=  $this->_serializeElement( $this->_fitTables[$i] );
        }
        return $html;
    }
   
   /**
    * Serailize elements recurive
    * 
    * @param array $el
    * @return string $html
    */
    private function _serializeElement( &$el ) 
    {
        $html   =   '';
        $html   .=  $el['before'];
        // starting tag
        $html   .=  '<' . $el['tag'];

        // attributes
        foreach( $el['attributes'] as $name => $value ) {
            if( $value == null ) {
                $html .= ' ' . $name;
                continue;
            }
            $html   .= ' ' . $name . '="' . $value . '"';
        }
        $html   .=  '>';
        
        if( !empty( $el['children'] ) ) {
            for( $i = 0; $i < count( $el['children'] ); ++$i ) {
                $html   .=  $this->_serializeElement( $el['children'][$i] ); 
            }
        }
        else {
            $html   .=  $el['cData'];
        }
        
        // end tag
        $html   .=  '</' . $el['tag'] . '>';
        $html   .=  $el['after'];
        
        return $html;
    }
    
   /**
    * Parse HTML for FIT-like-tables
    *
    * Parse string like SAX does: call sub routines based on events. 
    * There are events for "startTag", "endTag" and "cdata". Of course 
    * this is not an XML parser (as the content may not follow XML standards)
    * Also it can only handle a bunch of well known tags. 
    * 
    * @param string $string to be parsed html
    * @return bool true on success
    */
    public function parse( $string ) 
    {
        $this->_fitTables       = array();
        $this->_fitTableCurrent = 0;
        $this->_fitRowCurrent   = 0;
        $this->_fitColCurrent   = 0;
    
        $regexp =   '/(<(\/?)([[:alnum:]:]*)[[:space:]]*([^>]*)>)/im';

        $tokens =   preg_split( $regexp, $string, -1, PREG_SPLIT_DELIM_CAPTURE );
        $this->_cData    = array();
        
        $count  =   count( $tokens );
        if( !empty( $tokens[0] ) ) {
            $this->_characterData( $tocken[0] );
        }

        $i      = 1;
        while( $i < $count ) {
            $fullTag    = $tokens[$i];
            $closing    = $tokens[++$i];
            $tagname    = strtolower( $tokens[++$i] );
            $attString  = $tokens[++$i];
            $empty      = substr( $attString, -1 );
            $data       = $tokens[++$i];
            // opening tag

            if( empty( $closing ) ) {
                $this->_startTag( $fullTag, $tagname, $attString );
            }

            // closing tag
            if( $closing ) {
                $this->_endTag( $fullTag, $tagname );
            }

            // keep character data
            $this->_characterData( $data );            

            ++$i;
        }

        // append tailing HTML
        if( !empty( $this->_cData[$this->_fitDepth] ) ) {
            $this->_fitTables[$this->_fitTableCurrent]['after'] = $this->_cData[$this->_fitDepth];
        }

        $this->_fitTableCurrent = 0;
        return true;
    }
    
   /**
    * handle starting tag
    * 
    * @param string $full full tag string like: '<table border="17" width="100%">'
    * @param string $tag extacted tag name like table
    * @param string $attString prepased attribute sting like 'border="17" width="100%"'
    * @return bool true on success
    */
    private function _startTag( $full, $tag, $attString  )
    {
        if( !in_array( $tag, $this->_fitTags ) ) {
            $this->_characterData( $full );
            return true;
        }

        $data   = array(
                'before'        => null,
                'fullStart'     => $full,
                'fullEnd'       => null,
                'tag'           => $tag,
                'attributes'    => $this->_parseAttributes( $attString ),
                'children'      => array(),
                'cData'         => '',
                'after'         => null,
            );
        
        switch( $tag ) {
            case 'table':
                $data['before'] = $this->_cData[$this->_fitDepth];
                $data['after']  = null;
                $this->_cData[$this->_fitDepth] = '';
                $this->_fitTableCurrent = count( $this->_fitTables );
                $this->_fitTables[$this->_fitTableCurrent] = $data;
                break;
                
            case 'tr':
                $this->_fitRowCurrent   = count( $this->_fitTables[$this->_fitTableCurrent]['children'] );
                $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent] = $data;
                break;
                
            case 'td':
                $data['cData']  =   str_replace( '&nbsp;', ' ', $data['cData'] );
                $this->_fitColCurrent   = count( $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent]['children'] );
                $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent]['children'][$this->_fitColCurrent] = $data;
                break;
                
             default:
                break;
        }
        ++$this->_fitDepth;        
        

        return true;
    }

   /**
    * handle end tag
    * 
    * @param string $full full tag string like: '</table>'
    * @param string $tag extacted tag name like 'table'
    * @return bool true on success
    */
    private function _endTag( $full, $tag )
    {
        if( !in_array( $tag, $this->_fitTags ) ) {
            $this->_characterData( $full );
            return true;
        }
        $cData = $this->_cData[$this->_fitDepth];
        switch( $tag ) {
            case 'table':
                $this->_fitTables[$this->_fitTableCurrent]['fullEnd'] = $full;
                break;
            
            case 'tr':
                $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent]['fullEnd'] = $full;
                $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent]['before'] = $cData;
                break;
                
            case 'td':
                $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent]['children'][$this->_fitColCurrent]['fullEnd'] = $full;
                $cData = trim( strip_tags( $cData ) );
                $this->_fitTables[$this->_fitTableCurrent]['children'][$this->_fitRowCurrent]['children'][$this->_fitColCurrent]['cData'] = $cData;
                break;
                
             default:
                break;
        }
        
        unset( $this->_cData[$this->_fitDepth] );
        --$this->_fitDepth;
        
        return true;
    }
    
   /**
    * handle character data
    * 
    * @param string $cdata - the stuff inside tags
    * @return bool true on success
    */
    private function _characterData( $data )
    {
        if( !isset( $this->_cData[$this->_fitDepth] ) ) {
            $this->_cData[$this->_fitDepth] = $data;
            return true;
        }
        $this->_cData[$this->_fitDepth] .= $data;
        return true;
    }
    
   /**
    * Parse an attribute string and build an array
    *
    * @param string attribute string
    * @param array attribute array
    */
    private function _parseAttributes( $string )
    {
        $attributes = array();
        if( empty( $string ) ) {
            return $attributes;
        }
        
        $match = array();
        if( !preg_match_all('/([a-zA-Z_0-9]+)(=?)([^\s]*)/', $string, $match ) ) {
            return $attributes;
        }
        
        for( $i = 0; $i < count( $match[0] ); ++$i ) {
            $name = strtolower( $match[1][$i] );
            
            // attribute has no value
            if( !$match[2][$i] ) {
                $attributes[$name] = null;
            }
            
            $value = $match[3][$i];
            $attributes[$name] = $this->_stripValue( $value );
        }
        
        return $attributes;
    }

   /**
    * strip quotes from values to be compliat with some wordish HTML formats
    * 
    * @param string $string value string 
    * @return string stripped value
    */
    private function _stripValue( $string ) 
    {
        if( !strlen( $string ) ) {
            return '';
        }
        
        // strip quotes
        $last = strlen( $string ) - 1;
        if( $string[0] == '"' && $string[$last] == '"' ) {
            $last -= 1;
            return substr( $string, 1, $last );
        }

        if( $string[0] == "'" && $string[$last] == "'" ) {
            $last -= 1;
            return substr( $string, 1, $last );
        }
        
        return $string;
    }

   /**
    * delete row
    *
    * delete a row
    * @param int $table
    * @param int $row
    * @return bool true on success
    */
    public function deleteRow( $table, $delRow )
    {
        $row = $this->countChildNodes( $table );
        $col = $this->countChildNodes( $table, $row-1 );

        if( $delRow > $row || $delRow < 0 ) {
            throw new Testing_FIT_Exception_Parser( 'Delete of row failed. Row index out of range!', $table, $row );
            return false;
        }
        else {
            if( $delRow == $this->countChildNodes( $table )  ) {
                // set the after string from to delete row to the last but one row -> last row
                $this->_fitTables[$table]['children'][$delRow-1]['after'] = $this->_fitTables[$table]['children'][$delRow]['after'];
                array_pop( $this->_fitTables[$table]['children'] );
                return true;
            }

            if( $delRow == 0 ) {
                $this->_fitTables[$table]['children'][1]['after'] = $this->_fitTables[$table]['children'][0]['after'];
                $this->_fitTables[$table]['children'] = array_slice( $this->_fitTables[$table]['children'], 1 );
                return true;
            }

            $partOne = array_slice( $this->_fitTables[$table]['children'], 0, $delRow );
            $partTwo = array_slice( $this->_fitTables[$table]['children'], $delRow+1, $this->countChildNodes( $table ) );
            $merged = array_merge( $partOne, $partTwo );
            $this->_fitTables[$table]['children'] = $merged;
        }

        return true;
    }

   /**
    * append row
    *
    * append a new row with the format form the old last row to table 
    * @param int $table
    * @return bool true on success
    */
    public function appendRow( $table )
    {
        $row    =   $this->countChildNodes( $table );
        $col    =   $this->countChildNodes( $table, $row-1 );
        $cell   =   $this->_getCellFormat( $table, $row );

        // build new row
        $newCells = array();
        for( $i = 0; $i < $col; $i++ ) {
            array_push( $newCells, $cell );
        }

        //get tags and attributes from actual table
        $newRow = array(
            'before'     => $this->_fitTables[$table]['children'][$row-1]['before'],
            'fullStart'  => $this->_fitTables[$table]['children'][$row-1]['fullStart'],
            'fullEnd'    => $this->_fitTables[$table]['children'][$row-1]['fullEnd'],
            'tag'        => $this->_fitTables[$table]['children'][$row-1]['tag'],
            'attributes' => $this->_fitTables[$table]['children'][$row-1]['attributes'],
            'children'   => $newCells,
            'cData'      => $this->_fitTables[$table]['children'][$row-1]['cData'],
            'after'      => $this->_fitTables[$table]['children'][$row-1]['after'],
                    );

        // unset the after information for last but one row = ""
        $this->_fitTables[$table]['children'][$row-1]['after'] = "";

        array_push( $this->_fitTables[$table]['children'], $newRow );
        $this->serialize( $this->_fitTables[$table] );

        return true;
    }

   /**
    * insert before row
    *
    * insert a new row in $table before $newRow
    * @param int $table
    * @param int $newRow
    * @return bool true on success
    * @todo look at fixture, because of first, second row and column
    */
    public function insertBeforeRow( $table, $newRow )
    {
        $row = $this->countChildNodes( $table );
        $col = $this->countChildNodes( $table, $row-1 );

       /*
        * es muss noch festgestell werden, welche fixture auf die tabelle angewandt wird.
        * bei col und row fixture sind die ersten beiden zeilen ( 0 und 1 ) fix!
        * bei action fixture ist die erste spalte und erste zeile fix!
        */
/*
        if( $newRow < 2 ) {
            return false;
        }
*/
        if( $newRow >= $row ) {
            $this->appendRow( $table );
            return true;
        }

        $insertRow = $this->_fitTables[$table]['children'][$newRow];
        for( $i = 0; $i < $col; $i++ ) {
            $insertRow['children'][$i]['cData'] = '&nbsp';
        }

        $part1 = array();
        $part2 = array();
        $part3 = array();

        $part3 = $this->_fitTables;
        $part3[$table]['children'] = null;

        $part1 = array_slice( $this->_fitTables[$table]['children'], 0, $newRow );
        $part1[] = $insertRow;

        $part2 = array_slice( $this->_fitTables[$table]['children'], $newRow, $this->countChildNodes( $table ) );
        $part1 = array_merge( $part1, $part2 );

        $part3[$table]['children'] = $part1;

        $this->_fitTables = $part3;

        return true;
    }

   /**
    * get cell format
    *
    * build a formatted raw cell
    * @param int $table
    * @param int $row
    * @return array $cell   new row with html attributes if the cell before got some
    */
    private function _getCellFormat( $table, $row )
    {
        // create cell
        $cell = array(
            'before'     => $this->_fitTables[$table]['children'][$row-1]['children'][0]['before'],
            'fullStart'  => $this->_fitTables[$table]['children'][$row-1]['children'][0]['fullStart'],
            'fullEnd'    => $this->_fitTables[$table]['children'][$row-1]['children'][0]['fullEnd'],
            'tag'        => $this->_fitTables[$table]['children'][$row-1]['children'][0]['tag'],
            'attributes' => $this->_fitTables[$table]['children'][$row-1]['children'][0]['attributes'],
            'children'   => $this->_fitTables[$table]['children'][$row-1]['children'][0]['children'],
            'cData'      => "&nbsp;",
            'after'      => $this->_fitTables[$table]['children'][$row-1]['children'][0]['after'],
                    );

        return $cell;
    }

   /**
    * append column
    *
    * append a new column at $table
    * @param int $table
    * @return array $cell contain one cell with attributes
    */
    public function appendColumn( $table )
    {
        $row = $this->countChildNodes( $table );
        $col = $this->countChildNodes( $table, $row-1 );

        $cell = array(
                'before'     => '',
                'fullStart'  => '',
                'fullEnd'    => '',
                'tag'        => 'td',
                'attributes' => array(),
                'children'   => array(),
                'cData'      => '&nbsp;',
                'after'      => '',
                    );

        for( $i = 0; $i < $row; $i++ ) {
            array_push( $this->_fitTables[$table]['children'][$i]['children'], $cell );
        }
        return true;
    }

   /**
    * insert before column
    *
    * insert a new column in $table before $column
    * if $column == last col of table the appendColumn function will be called
    *
    * @param int $table
    * @param int $column
    * @return bool true on success
    */
    public function insertBeforeColumn( $table, $column )
    {
        if( $column <= -1 ) {
            return false;
        }

        $row = $this->countChildNodes( $table );
        $col = $this->countChildNodes( $table, $row-1 );

        if( $col == $column ) {
            return $this->appendColumn( $table );
        }

        $cell = array(
                'before'     => '',
                'fullStart'  => '',
                'fullEnd'    => '',
                'tag'        => 'td',
                'attributes' => array(),
                'children'   => array(),
                'cData'      => '&nbsp;',
                'after'      => '',
                    );

        $part1 = array();
        $part2 = array();

        for( $i = 0; $i < $row; $i++ ) {
            array_push( $part1, array_slice( $this->_fitTables[$table]['children'][$i]['children'], 0, $column ) );
            array_push( $part2, array_slice( $this->_fitTables[$table]['children'][$i]['children'], $column ) );

            array_push( $part1[$i], $cell );
            for( $j = 0; $j < count( $part2[$i] ); $j++ ) {
                array_push( $part1[$i], $part2[$i][$j] );
            }
        }

        for( $i = 0; $i < $row; $i++ ) {
            $this->_fitTables[$table]['children'][$i]['children'] = $part1[$i];
        }

        return true;
    }

   /**
    * delete before column
    *
    * deletes exactly that $cell in that $row
    * only to use in combination with colspan
    * be careful to use this funtion!
    *
    * @param int $table
    * @param int $row
    * @param int $col
    */
    public function deleteBeforeColumn( $table, $row, $col )
    {
        $lastCol = count( $this->_fitTables[$table]['children'][$row]['children'] );

        if( $col == $lastCol ) {
            $part = array_slice( $this->_fitTables[$table]['children'][$row]['children'], 0, $col - 1 );
            $this->_fitTables[$table]['children'][$row]['children'] = $part;
            return;
        }

        $part1 = array();
        $part2 = array();

        $part1 = array_slice( $this->_fitTables[$table]['children'][$row]['children'], 0, $col );
        $part2 = array_slice( $this->_fitTables[$table]['children'][$row]['children'], $col + 1, $lastCol );

        $part1 = array_merge( $part1, $part2 );
        $this->_fitTables[$table]['children'][$row]['children'] = $part1;

        return;
    }

   /**
    * append table
    *
    * appends some content after $table
    *
    * @param int $table
    * @param int $content   some html content
    */
    public function appendTable( $table, $content )
    {
        // create new table
        $tmpParser = new Testing_FIT_Parser();
        $tmpParser->parse( $content );
        $insert = $tmpParser->_fitTables[0];

        $tmpTable = array();
        for( $i = 0; $i <= $table; $i++ ) {
            $tmpTable[] = $this->_fitTables[$i];
        }

        if( $tmpTable[$table]['after'] ) {
            $after = $tmpTable[$table]['after'];
            $tmpTable[$table]['after'] = null;
            $insert['after'] = $after;
        }

        $tmpTable[] = $insert;

        $table += 1;
        if( isset( $this->_fitTables[$table] ) ) {
            for( $i = $table ; $i < $this->countChildNodes(); $i++ ) {
                $tmpTable[] = $this->_fitTables[$i];
            }
        }

        $this->_fitTables = $tmpTable;
    }

   /**
    * remove table
    *
    * remove fit summary table
    *
    * @param int $table
    */
    public function removeTable( $table )
    {
        $after = $this->_fitTables[$table+1]['after'];
        $this->_fitTables[$table]['after'] = $after;
        unset( $this->_fitTables[$table+1] );

        return true;
    }
}
?>