<?php
class Subversion
{
    public $client;
    public $root;
    public $account;
    public $password;
    public $ssh;
    public $remote;
    public $encoding;
    public $svnVersion;

    /**
     * Construct 
     * 
     * @param  string $client 
     * @param  string $root 
     * @param  string $account 
     * @param  string $password 
     * @param  string $encoding 
     * @access public
     * @return void
     */
    public function __construct($client, $root, $account, $password, $encoding = 'UTF-8')
    {
        putenv('LC_CTYPE=en_US.UTF-8');
        $this->root     = str_replace(array('%3A', '%2F', '+'), array(':', '/', ' '), urlencode(rtrim($root, '/')));
        $this->account  = $account;
        $this->password = $password;
        $this->encoding = $encoding;
        $this->ssh      = (stripos($this->root, 'svn') === 0 or stripos($this->root, 'https') === 0) ? true : false;
        $this->remote   = !(stripos($this->root, 'file') === 0);
        $this->client   = $this->remote ? $client . " --username @account@ --password @password@" : $client;
        if($this->encoding == 'utf-8') $this->encoding = 'gbk';

        $this->svnVersion = $this->getSVNVersion($client);
    }

    /**
     * List files. 
     * 
     * @param  string $path 
     * @param  string $revision 
     * @access public
     * @return array
     */
    public function ls($path, $revision = 'HEAD')
    {
        if(!scm::checkRevision($revision)) return array();

        $resourcePath = $path;
        $path = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($path)) . '"';
        $cmd  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'ls', "-r $revision --xml")));
        $list = execCmd($cmd, 'string', $result);
        if($result)
        {
            $path = '"' . $this->root . '/' . $resourcePath . '"';
            $cmd  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'ls', "-r $revision --xml")));
            $list = execCmd($cmd, 'string', $result);
            if($result) $list = '';
        }
        $listObject = simplexml_load_string($list);
        if(!empty($list) and empty($listObject))
        {
            $list = helper::convertEncoding($list, $this->encoding, 'utf-8');
            $listObject = simplexml_load_string($list);
        }
        if(!empty($listObject->list->entry)) $listObject = $listObject->list->entry;
        $infos = array();
        if(empty($listObject)) return $infos;

        foreach($listObject as $list)
        {
            $info = new stdclass();
            $info->name     = (string)$list->name;
            $info->kind     = (string)$list['kind'];
            $info->revision = (int)$list->commit['revision'];
            $info->account  = (string)$list->commit->author;
            $info->date     = date('Y-m-d H:i:s', strtotime($list->commit->date));
            $info->size     = $info->kind == 'file' ? (int)$list->size > 1024 ? round((int)$list->size / 1024, 2) . "KB" : (int)$list->size . 'Bytes' : 0;
            $info->comment  = '';
            $infos[]        = $info;
        }

        /* Sort by kind */
        foreach($infos as $key => $info) $kind[$key] = $info->kind;
        if($infos) array_multisort($kind, SORT_ASC, $infos);

        return $infos;
    }

    /**
     * Get tags.
     * 
     * @param  string $path 
     * @param  string $revision 
     * @param  bool   $onlyDir 
     * @access public
     * @return array
     */
    public function tags($path, $revision = 'HEAD', $onlyDir = true)
    {
        if(!scm::checkRevision($revision)) return array();

        $infos = $this->ls($path, $revision);
        $dirs  = array();
        foreach($infos as $info)
        {
            if($onlyDir and $info->kind != 'dir') continue;
            $dirs[$info->date][$info->name] = $info->name;
        }

        ksort($dirs);
        $tags   = array();
        $trimed = trim($path, '/');
        $prefix = empty($trimed) ? '/' : '/' . $trimed . '/';
        foreach($dirs as $dirNames)
        {
            ksort($dirNames);
            foreach($dirNames as $dirName)
            {
                $dirPath = $prefix . $dirName;
                $tags[$dirPath] = $dirName;
            }
        }

        return $tags;
    }

    /**
     * Get branch.
     * 
     * @access public
     * @return array
     */
    public function branch()
    {
        return array();
    }

    /**
     * Get last log.
     * 
     * @param  string $path 
     * @param  int    $count 
     * @access public
     * @return array
     */
    public function getLastLog($path, $count = 10)
    {
        $resourcePath = $path;
        $path = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($path)) . '"';
        $cmd  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'log', "--limit $count --xml")));
        $comments = execCmd($cmd, 'string', $result);
        if($result)
        {
            $path = '"' . $this->root . '/' . $resourcePath . '"';
            $cmd  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'log', "--limit $count --xml")));
            $comments = execCmd($cmd, 'string', $result);
            if($result) $comments = '';
        }

        $parsedComments = simplexml_load_string($comments);
        if(!empty($comments) and empty($parsedComments))
        {
            $comments = helper::convertEncoding($comments, $this->encoding, 'utf-8');
            $parsedComments = simplexml_load_string($comments);
        }
        $logs = array();
        foreach($parsedComments->logentry as $entry)
        {
            $log = new stdclass();
            $log->committer = (string)$entry->author; 
            $log->revision  = (int)$entry['revision']; 
            $log->comment   = trim((string)$entry->msg);
            $log->time      = date('Y-m-d H:i:s', strtotime($entry->date));
            $log->change    = array();
            $logs[]         = $log;
            unset($log);
        }

        /* Sort by kind */
        foreach($logs as $key => $log) $revision[$key] = $log->revision;
        if($logs) array_multisort($revision, SORT_DESC, $logs);

        return $logs;
    }

    /**
     * Get log.
     * 
     * @param  string $path 
     * @param  int    $fromRevision 
     * @param  string $toRevision 
     * @param  int    $count 
     * @param  bool   $quiet 
     * @access public
     * @return array
     */
    public function log($path, $fromRevision = 0, $toRevision = 'HEAD', $count = 0, $quiet = false)
    {
        if(!scm::checkRevision($fromRevision)) return array();
        if(!scm::checkRevision($toRevision))   return array();

        $resourcePath = $path;
        $count = $count == 0 ? '' : "--limit $count";
        $param = $quiet ? '-q' : '-v';
        $path  = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($path)) . '"';
        $cmd   = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'log', "$count $param -r $fromRevision:$toRevision --xml")));
        $comments = execCmd($cmd, 'string', $result);
        if($result)
        {
            $path = '"' . $this->root . '/' . $resourcePath . '"';
            $cmd  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'log', "$count $param -r $fromRevision:$toRevision --xml")));
            $comments = execCmd($cmd, 'string', $result);
            if($result) $comments = '';
        }

        $parsedComments = simplexml_load_string($comments);
        if(!empty($comments) and empty($parsedComments))
        {
            $comments = helper::convertEncoding($comments, $this->encoding, 'utf-8');
            $parsedComments = simplexml_load_string($comments);
        }
        $logs     = array();
        $revision = array();
        if(empty($parsedComments->logentry)) return $logs;

        foreach($parsedComments->logentry as $entry)
        {
            $log = new stdclass();
            $log->committer = (string)$entry->author; 
            $log->revision  = (int)$entry['revision']; 
            $log->comment   = trim((string)$entry->msg);
            $log->time      = date('Y-m-d H:i:s', strtotime($entry->date));
            $log->change    = array();
            if(!empty($entry->paths))
            {
                foreach($entry->paths->path as $path) 
                {
                    $pathInfo = array();
                    foreach($path->attributes() as $attr => $value) $pathInfo[$attr] = (string)$value;    
                    $log->change[(string)$path] = $pathInfo;
                }
            }
            if(in_array($log->revision, $revision)) continue;

            $logs[]     = $log;
            $revision[] = $log->revision;
            unset($log);
        }

        /* Sort by kind */
        if($logs) array_multisort($revision, SORT_DESC, $logs);
        return $logs;
    }

    /**
     * Blame file.
     * 
     * @param  string $path 
     * @param  int    $revision 
     * @access public
     * @return array
     */
    public function blame($path, $revision)
    {
        if(!scm::checkRevision($revision)) return array();

        $resourcePath = $path;
        $path   = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($path)) . '"';
        $file   = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'cat', "-r $revision")));
        $blame  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'blame', "-r $revision --xml")));
        $output = execCmd($blame, 'string', $result);
        if($result)
        {
            $path   = '"' . $this->root . '/' . $resourcePath . '"';
            $file   = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'cat', "-r $revision")));
            $blame  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'blame', "-r $revision --xml")));
            $output = execCmd($blame, 'string', $result);

            if($result) return array();
        }

        $content = execCmd($file, 'array');

        $parsedResult = simplexml_load_string($output);
        if(!empty($output) and empty($parsedResult))
        {
            $output = helper::convertEncoding($output, $this->encoding, 'utf-8');
            $parsedResult = simplexml_load_string($output);
        }

        $blames   = array();
        $revLine  = 0;
        $revision = '';
        if($parsedResult->target->entry)
        {
            foreach($parsedResult->target->entry as $line)
            {
                if($line->commit['revision'] != $revision) 
                {
                    $blame = array();
                    $blame['revision']  = (int)$line->commit['revision'];
                    $blame['committer'] = (string)$line->commit->author;
                    $blame['time']      = substr($line->commit->date, 0, 10);
                    $blame['line']      = (int)$line['line-number'];
                    $blame['lines']     = 1;
                    $blame['content']   = $content[$blame['line'] - 1];
                    $revision         = $blame['revision'];
                    $revLine          = $blame['line'];
                    $blames[$revLine] = $blame;
                }
                else
                {
                    $blame            = array();
                    $blame['line']    = (int)$line['line-number'];
                    $blame['content'] = $content[$blame['line'] - 1];

                    $blames[$blame['line']] = $blame;
                    $blames[$revLine]['lines'] ++;
                }
            }
        }
        return $blames;
    }

    /**
     * Diff file.
     * 
     * @param  string $path 
     * @param  int    $fromRevision 
     * @param  int    $toRevision 
     * @access public
     * @return array
     */
    public function diff($path, $fromRevision, $toRevision)
    {
        if(!scm::checkRevision($fromRevision)) return array();
        if(!scm::checkRevision($toRevision))   return array();

        $resourcePath = $path;
        if($fromRevision == '^') $fromRevision = $toRevision - 1;
        $path = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($path)) . '"';
        $cmd  = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'diff', "-r $fromRevision:$toRevision")));
        $lines = execCmd($cmd, 'array', $result);
        if($result)
        {
            $path  = '"' . $this->root . '/' . $resourcePath . '"';
            $cmd   = $this->replaceAuth(escapeCmd($this->buildCMD($path, 'diff', "-r $fromRevision:$toRevision")));
            $lines = execCmd($cmd, 'array', $result);
        }
        return $lines;
    }

    /**
     * Cat file.
     * 
     * @param  string $entry 
     * @param  string $revision 
     * @access public
     * @return string
     */
    public function cat($entry, $revision = 'HEAD')
    {
        if(!scm::checkRevision($revision)) return false;

        $resourcePath = $entry;
        $entry = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($entry)) . '"';
        $cmd   = $this->replaceAuth(escapeCmd($this->buildCMD($entry, 'cat', "-r $revision")));
        $content = execCmd($cmd, 'string', $result);
        if($result)
        {
            $entry = '"' . $this->root . '/' . $resourcePath . '"';
            $cmd   = $this->replaceAuth(escapeCmd($this->buildCMD($entry, 'cat', "-r $revision")));
            $content = execCmd($cmd, 'string', $result);
        }
        return $content;
    }

    /**
     * Get info.
     * 
     * @param  string $entry 
     * @param  string $revision 
     * @access public
     * @return object
     */
    public function info($entry, $revision = 'HEAD')
    {
        if(!scm::checkRevision($revision)) return false;

        $resourcePath = $entry;
        $entry   = '"' . $this->root . '/' . str_replace(array('%2F', '+'), array('/', ' '), urlencode($entry)) . '"';
        $svnInfo = $this->replaceAuth(escapeCmd($this->buildCMD($entry, 'info', "-r $revision --xml")));

        $svninfo = execCmd($svnInfo, 'string', $result);
        if($result)
        {
            $entry   = '"' . $this->root . '/' . $resourcePath . '"';
            $svnInfo = $this->replaceAuth(escapeCmd($this->buildCMD($entry, 'info', "-r $revision --xml")));

            $svninfo = execCmd($svnInfo, 'string', $result);
            if($result) $svninfo = '';
        }

        $parsedSvnInfo = simplexml_load_string($svninfo);
        if(!empty($svninfo) and empty($parsedSvnInfo))
        {
            $svninfo = helper::convertEncoding($svninfo, $this->encoding, 'utf-8');
            $parsedSvnInfo = simplexml_load_string($svninfo);
        }
        $info = new stdclass();
        $info->kind      = empty($parsedSvnInfo->entry['kind'])             ? '' : (string)$parsedSvnInfo->entry['kind'];
        $info->path      = empty($parsedSvnInfo->entry['path'])             ? '' : (string)$parsedSvnInfo->entry['path'];
        $info->revision  = empty($parsedSvnInfo->entry['revision'])         ? '' : (int)$parsedSvnInfo->entry['revision'];
        $info->cRevision = empty($parsedSvnInfo->entry->commit['revision']) ? '' : (int)$parsedSvnInfo->entry->commit['revision'];
        $info->root      = empty($parsedSvnInfo->entry->repository->root)   ? '' : (string)$parsedSvnInfo->entry->repository->root;
        return $info;
    }

    /**
     * Exec svn cmd.
     * 
     * @param  string $cmd 
     * @access public
     * @return array
     */
    public function exec($cmd)
    {
        $cmd = $this->replaceAuth(escapeCmd($this->buildCMD('', $cmd, '')));
        return execCmd($cmd, 'array');
    }

    /**
     * Parse diff.
     * 
     * @param  array  $lines 
     * @access public
     * @return array
     */
    public function parseDiff($lines)
    {
        if(empty($lines)) return array();
        $diffs   = array();
        $num     = count($lines);
        $endLine = end($lines);
        if(strpos($endLine, '\ No newline at end of file') === 0) $num -= 1;

        for($i = 0; $i < $num; $i ++)
        {
            $diffFile = new stdclass();
            if(strpos($lines[$i], "Index: ") === 0)
            {
                $fileName = str_replace('Index: ', '', $lines[$i]);
                $diffFile->fileName = $fileName;
                for($i++; $i < $num; $i ++)
                {
                    $diff = new stdclass();
                    if(strpos($lines[$i], '+++', 0) !== false)    continue;
                    if(strpos($lines[$i], '---', 0) !== false)    continue;
                    if(strpos($lines[$i], '======', 0) !== false) continue;
                    if(preg_match('/^@@ -(\\d+)(,(\\d+))?\\s+\\+(\\d+)(,(\\d+))?\\s+@@\\s*($)/A', $lines[$i]))
                    {
                        $startLines = trim(str_replace(array('@', '+', '-'), '', $lines[$i]));
                        list($oldStartLine, $newStartLine) = explode(' ', $startLines);
                        list($diff->oldStartLine) = explode(',', $oldStartLine);
                        list($diff->newStartLine) = explode(',', $newStartLine);
                        $oldCurrentLine = $diff->oldStartLine;
                        $newCurrentLine = $diff->newStartLine;
                        $newLines = array();
                        for($i++; $i < $num; $i ++)
                        {
                            if(preg_match('/^@@ -(\\d+)(,(\\d+))?\\s+\\+(\\d+)(,(\\d+))?\\s+@@\\s*($)/A', $lines[$i]))
                            {
                                $i --;
                                break;
                            }
                            if(strpos($lines[$i], "Index: ") === 0) break;

                            $line = $lines[$i];
                            if(strpos($line, '\ No newline at end of file') === 0)continue;
                            $sign = empty($line) ? '' : $line[0];
                            $type = $sign != '-' ? $sign == '+' ? 'new' : 'all' : 'old';
                            if($sign == '-' || $sign == '+') $line = substr_replace($line, ' ', 1, 0);

                            $newLine = new stdclass();
                            $newLine->type  = $type;
                            $newLine->oldlc = $type != 'new' ? $oldCurrentLine : '';
                            $newLine->newlc = $type != 'old' ? $newCurrentLine : '';
                            $newLine->line  = $line;

                            if($type != 'new') $oldCurrentLine++;
                            if($type != 'old') $newCurrentLine++;

                            $newLines[] = $newLine;
                        }

                        $diff->lines = $newLines;
                        $diffFile->contents[] = $diff;
                    }

                    if(isset($lines[$i]) and strpos($lines[$i], "Index: ") === 0)
                    {
                        $i --;
                        break;
                    }
                }
                $diffs[] = $diffFile;
            }
        }
        return $diffs;
    }

    /**
     * Get commit count.
     * 
     * @param  int    $commits 
     * @param  int    $lastVersion 
     * @access public
     * @return int
     */
    public function getCommitCount($commits = 0, $lastVersion = 0)
    {
        if(!scm::checkRevision($lastVersion)) return false;

        if(empty($commits))     $commits     = 0;
        if(empty($lastVersion)) $lastVersion = 0;
        $lastRevision = $this->getLatestRevision();

        $count = 10000;
        $from  = $lastVersion;
        while(true)
        {
            $logs     = $this->log('', $from, $lastRevision, empty($from) ? $count : $count + 1, $quiet = true);
            if(empty($logs)) break;

            $num      = empty($from) ? count($logs) : count($logs) - 1;
            $commits += $num;

            $from = reset($logs);
            $from = $from->revision;
            if($from == $lastRevision) break;
        }
        return $commits;
    }

    /**
     * Get first revision.
     * 
     * @access public
     * @return int
     */
    public function getFirstRevision()
    {
        $logs     = $this->log('', 0, 'HEAD', 1, $quiet = true);
        if(empty($logs)) return 0;
        $firstLog = end($logs);
        return $firstLog->revision;
    }

    /**
     * Get latest revision.
     * 
     * @access public
     * @return int
     */
    public function getLatestRevision()
    {
        $info = $this->info('');
        return $info->cRevision;
    }

    /**
     * Get commits.
     * 
     * @param  string $version 
     * @param  int    $count 
     * @access public
     * @return array
     */
    public function getCommits($version = '', $count = 0)
    {
        if(!scm::checkRevision($version)) return array();

        $count = $count == 0 ? '' : "--limit $count";
        $path  = '"' . $this->root . '"';
        if(stripos($this->root, 'https') === 0 or stripos($this->root, 'svn') === 0)
        {
            $comments = str_replace("\\", "/", "$this->client log $count -v -r $version:0 --non-interactive --trust-server-cert-failures=cn-mismatch --trust-server-cert --no-auth-cache --xml $path");
            if($this->svnVersion and version_compare($this->svnVersion, '1.9', '<')) $comments = str_replace("\\", "/", "$this->client log $count -v -r $version:0 --non-interactive --trust-server-cert --no-auth-cache --xml $path");
        }
        else
        {
            $comments = str_replace("\\", "/", "$this->client log $count -v -r $version:0 --no-auth-cache --xml $path");
        }
        $comments = $this->replaceAuth(escapeCmd($comments));
        $comments = execCmd($comments, 'string', $result);
        if($result) $comments = '';

        $parsedComments = simplexml_load_string($comments);
        if(!empty($comments) and empty($parsedComments))
        {
            $comments = helper::convertEncoding($comments, $this->encoding, 'utf-8');
            $parsedComments = simplexml_load_string($comments);
        }
        $logs = array();
        foreach($parsedComments->logentry as $entry)
        {
            $parsedLog            = new stdClass();
            $parsedLog->committer = (string)$entry->author; 
            $parsedLog->revision  = (int)$entry['revision']; 
            $parsedLog->comment   = trim((string)$entry->msg);
            $parsedLog->time      = date('Y-m-d H:i:s', strtotime($entry->date));
            $logs['commits'][$parsedLog->revision] = $parsedLog;
            $logs['files'][$parsedLog->revision]   = array();
            if(!empty($entry->paths))
            {
                foreach($entry->paths->path as $file)
                {
                    $parsedFile = new stdclass();
                    $parsedFile->revision = $parsedLog->revision;
                    $parsedFile->path     = (string)$file;
                    $parsedFile->type     = (string)$file['kind'];
                    $parsedFile->action   = (string)$file['action'];
                    $logs['files'][$parsedLog->revision][]  = $parsedFile;
                }
            }
        }
        return $logs;
    }

    /**
     * Replace svn auth.
     * 
     * @param  string $cmd 
     * @access public
     * @return string
     */
    public function replaceAuth($cmd)
    {
        return str_replace(array('@account@', '@password@'), array($this->account, $this->password), $cmd);
    }

    /**
     * Build command. 
     * 
     * @param  string $path 
     * @param  string $action 
     * @param  string $param 
     * @access public
     * @return string
     */
    public function buildCMD($path, $action, $param)
    {
        if($this->ssh)
        {
            $cmd = str_replace("\\", "/", "$this->client $action $param --non-interactive --trust-server-cert-failures=cn-mismatch --trust-server-cert --no-auth-cache $path");
            if($this->svnVersion and version_compare($this->svnVersion, '1.9', '<')) $cmd = str_replace("\\", "/", "$this->client $action $param --non-interactive --trust-server-cert --no-auth-cache $path");
        }
        else
        {
            $cmd = str_replace("\\", "/", "$this->client $action $param --no-auth-cache $path");
        }

        return $cmd;
    }

    /**
     * Get SVN version.
     * 
     * @param  string $client 
     * @access public
     * @return string
     */
    public function getSVNVersion($client)
    {
        $versionCommand = "$client --version --quiet 2>&1";
        exec($versionCommand, $versionOutput, $versionResult);
        if($versionResult) return false;

        return end($versionOutput);
    }
}
