#!/usr/bin/env php
<?php
/**
 * The package manager for zos.
 * 
 * @copyright Copyright 2009-2015 QingDao Nature Easy Soft Network Technology Co,LTD (www.cnezsoft.com)
 * @author    chunsheng wang <chunsheng@cnezsoft.com> 
 * @package   zos
 * @license   LGPL
 * @version   $Id$
 * @Link      http://www.zentao.net
 */

$pkg = new pkg();

class pkg
{
    /**
     * The run mode: install|remove|browse|view|help|build.
     * 
     * @var string   
     * @access private
     */
    private $mode;

    /**
     * The params for one mode.
     * 
     * @var string   
     * @access private
     */
    private $params;

    /**
     * The sqlite database file.
     * 
     * @var string   
     * @access private
     */
    private $dbFile;

    /**
     * The sqlite handler.
     * 
     * @var object   
     * @access private
     */
    private $dbh;

    /**
     * The build dir.
     * 
     * @var string   
     * @access private
     */
    private $buildDir;

    /**
     * The install dir.
     * 
     * @var string   
     * @access private
     */
    private $installDir;

    /**
     * The construct function.
     * 
     * @param  string    $dbFile 
     * @access public
     * @return void
     */
    public function  __construct()
    {
        $this->getOptions();
        $this->setInstallDir();
        $this->init();

        if($this->mode == 'help')    $this->help();
        if($this->mode == 'watch')   $this->watch($this->params);
        if($this->mode == 'savelog') $this->saveLog($this->params);
        if($this->mode == 'build')   $this->setBuildDir() && $this->build($this->params);
        if($this->mode == 'install') $this->install($this->params);
        if($this->mode == 'browse')  $this->browse($this->params);
        if($this->mode == 'view')    $this->view($this->params);
        if($this->mode == 'remove')  $this->remove($this->params);
    }

    /**
     * Set the install dir.
     * 
     * @access public
     * @return void
     */
    public function setInstallDir()
    {
        $this->installDir = '/';
        //if(!is_dir($this->installDir)) mkdir($this->installDir);
    }

    /**
     * Init the database.
     * 
     * @access public
     * @return void
     */
    public function init()
    {
        if(!extension_loaded('sqlite3')) return false;

        $this->dbFile = $this->installDir . 'var/lib/pkg/pkg.db';
        $dbFilePath = dirname($this->dbFile);
        if(!is_dir($dbFilePath)) `mkdir -p $dbFilePath`;

        $createTable = !file_exists($this->dbFile);
        $this->openDB();
        $this->createTables($createTable);
    }

    /**
     * Open the database.
     * 
     * @access public
     * @return void
     */
    public function openDB()
    {
        $this->dbh = new sqlite3($this->dbFile);
    }

    /**
     * Create tables.
     * 
     * @param  bool    $createTable 
     * @access public
     * @return void
     */
    public function createTables($createTable)
    {
        if(!$createTable) return false;

        $sql  = "CREATE TABLE package(id INTEGER PRIMARY KEY, name TEXT, version TEXT, mode TEXT, desc TEXT, maintainer TEXT, depends TEXT,";
        $sql .= "preUninstall TEXT, postUninstall TEXT);";
        $sql .= "CREATE INDEX pkg_index on package(name, version);";
        $sql .= "CREATE TABLE file(id INTEGER PRIMARY KEY, package TEXT, version TEXT, path TEXT, owner TEXT, mode TEXT, md5 TEXT);";
        $sql .= "CREATE INDEX path on file(path);";
        $sql .= "CREATE INDEX file_pkg on file(package, version);";

        $this->dbh->exec($sql);
    }

    /**
     * Write the logfile name into the notifier file, thus the winst shell can write the files to it.
     *
     * It's in chroot env when building lfs, pkg was called in chroot, winst was called out of chroot,
     * so I need the prefix var to tell winst shell the real path of logfile.
     * 
     * @param  string    $prefix 
     * @access public
     * @return void
     */
    public function watch($prefix)
    {
        /* Get working dir. */
        $workDir = getcwd() . '/';

        /* Get the notifer file name and then write the logfile to it. 
         * Thus the winst shell can get the log filename from the notifier
         * file and then save log to it..
         */
        $notifierFile = dirname(__FILE__) . '/.notifier';
        $notifierContent = $prefix . $workDir . 'winst.log';
        file_put_contents($notifierFile, $notifierContent);

        /* Set the winst.log to empty by default. */
        file_put_contents($workDir . 'winst.log', '');
    }

    /**
     * Save winst.log.
     * 
     * @param  string    $package 
     * @access public
     * @return void
     */
    public function saveLog($package)
    {
        /* Make sure the logfile is not changed anymore. */
        $logFile = getcwd() . '/winst.log';
        $mtime = filemtime($logFile);
        while(true)
        {
            sleep(5);
            clearstatcache();
            if(filemtime($logFile) == $mtime) break;
            $mtime = filemtime($logFile);
        }

        copy($logFile, dirname(__FILE__) . "/log/$package.log");
    }

    /**
     * Set the build dir.
     * 
     * @access public
     * @return void
     */
    public function setBuildDir()
    {
        $this->buildDir = getenv('HOME') . '/packages/';
        if(!is_dir($this->buildDir)) mkdir($this->buildDir);
        return true;
    }

    /**
     * Build a package.
     *
     * Build a package through a pkg.ini file and pkg.log or just a pkg.ini. 
     * If the pkg.log given, get files from the log file. If not, read files 
     * at the same directory of pkg.ini.
     *
     * @param  mixed    $params  array(pkg.ini, pkg.log) or array($pkg.ini, pkg.log, mode=full|lite) or just pkg.ini.  
     * @access public
     * @return void
     */
    public function build($params)
    {
        /* Get pkg.ini file, set package info. */
        $pkgIniFile = is_array($params) ? realpath($params[0]) : realpath($params);
        if(!is_file($pkgIniFile)) die("the pkg.ini file not exists.\n");
        $package = $this->parsePackageINI($pkgIniFile);

        /* Set model, the default mode is full. full|lite. */
        $mode = isset($params[2]) ? $params[2] : 'full';

        /* Get files. */
        if(is_array($params))
        {
            $pkgLogFile = $params[1];
            if(!is_file($pkgLogFile)) die("The log file not exists.\n");

            $files = $this->getFilesFromLog($pkgLogFile, $mode);
        }
        else
        {
            $prefix = dirname($pkgIniFile) . '/';
            $files  = $this->getFilesByPackageINI($pkgIniFile);
        }

        /* Set package vars and init the directory. */
        $packageDir = $this->buildDir . $package->name . ".$package->version/";
        $packageTar = "$this->buildDir{$package->name}.{$package->version}.{$mode}.tar.gz";

        if(!is_dir($packageDir)) echo `mkdir -v -p $packageDir`;
        if(is_dir($packageDir))  echo `rm -fr $packageDir*`;
        if(is_file($packageTar)) echo `rm -frv $packageTar`;

        /* Copy files. */
        foreach($files as $file)
        {
            /* Skip cache files. */
            if(strpos($file, 'ld.so.cache') !== false or 
               strpos($file, 'aux-cache')   !== false) continue;

            /* Skip git-core for sparsed file reason. */
            if(strpos($file, '/usr/libexec/git-core') !== false) continue;

            /* Skip file not exists. */
            if(!file_exists($file))
            {
                echo "$file not exists, skip.\n";
                continue;
            }

            /* Copy files. */
            echo `cp --parents -av $file $packageDir`;    // Use parents options to copy direcoties.
        }

        /* Manualy copy git-core directoy. */
        if($package->name == 'git') echo `cp --parents -av /usr/libexec/git-core/ $packageDir`;

        /* Copy pkg.ini file. */
        $iniContents = file_get_contents($pkgIniFile);

        /* If mode not is full, insert the section into the pkg.ini file. */
        if($mode != 'full')
        {
            $iniLines = explode("\n", $iniContents);
            $iniLines[1] .= "\nmode=\"$mode\"";
            $iniContents = join("\n", $iniLines);
        }
        file_put_contents("{$packageDir}pkg.ini", $iniContents);

        /* Make the tar package and remove the tmp files. */
        echo `cd $this->buildDir && tar cpzvf $packageTar $package->name.$package->version`;
        echo `rm -frv $packageDir`;
    }

    /**
     * Get files from the winst log file.
     * 
     * @param  string    $logFile 
     * @param  string    $mode     full|lite 
     * @access public
     * @return array
     */
    public function getFilesFromLog($logFile, $mode = 'full')
    {
        $files = array();
        $lines = explode("\n", trim(file_get_contents($logFile)));
        $files = array_unique($lines);
        foreach($files as $key => $file)
        {
            if(!file_exists($file)) unset($files[$key]);

            /* If the mode is lite, don't include doc and header files. */
            if($mode == 'lite') 
            {
                if(strpos($file, '/usr/share/man')  !== false) unset($files[$key]);
                if(strpos($file, '/usr/share/doc')  !== false) unset($files[$key]);
                if(strpos($file, '/usr/share/info') !== false) unset($files[$key]);
                if(strpos($file, '/usr/include/')   !== false) unset($files[$key]);
            }
        }
        return array_unique($files);
    }

    /**
     * Give a pkg ini file and get files in it's directoy.
     * 
     * @param  string    $iniFile 
     * @access public
     * @return array
     */
    public function getFilesByPackageINI($iniFile)
    {
        $files = array();

        $path  = dirname(realpath($iniFile)) . '/';
        $lines = explode("\n", trim(`find $path* | sort`));
        foreach($lines as $file)
        {
            if(is_dir($file) or strpos($file, 'pkg.ini') !== false) continue;
            $file = str_replace($path, './', $file);    // Remove the prefix.
            $files[] = $file;
        }

        return array_unique($files);
    }

    /**
     * Install a package.
     * 
     * @param  mixed    $params    just the package filename or array(packagename, onlydb)
     * @access public
     * @return void
     */
    public function install($params)
    {
        /* Set params. */
        if(is_array($params))
        {
            $package = $params[0];
            $onlyDB  = $params[1];
        }
        else
        {
            $package = $params;
            $onlyDB  = false;
        }

        if(!is_file($package)) die("$package not exists\n");

        /* Extract files. */
        $packageTar  = realpath($package);
        $packageName = basename($package, '.tar.gz');
        $packageName = basename($packageName, '.full');
        $packageName = basename($packageName, '.lite');

        $temp = '/tmp/pkg.' . uniqid() . '/';
        echo `mkdir $temp && cd $temp && tar xvpf $packageTar`;

        /* Change to the package directory extracted. */
        chdir($temp . $packageName);

        /* Get package ini file and parse it. */
        $pkgINI = $temp . $packageName . '/pkg.ini';
        $package = $this->parsePackageINI($pkgINI);
        foreach($package as $key => $value) $package->$key = sqlite3::escapeString($value);

        /* Excute the preinstall shell. */
        if($package->preinstall) echo `$package->preinstall`;

        /* Save into package table. */
        $sql  = "DELETE FROM package WHERE    name = '$package->name' AND version = '$package->version';";
        $sql .= "DELETE FROM file    WHERE package = '$package->name' AND version = '$package->version';";
        $sql .= "INSERT INTO package (`name`, `version`, `mode`, `desc`, `maintainer`, `depends`, `preuninstall`, `postuninstall`) 
                 VALUES ('$package->name', '$package->version', '$package->mode', '$package->desc', '$package->maintainer', '$package->depends', '$package->preuninstall', '$package->postuninstall')";
        $this->dbh->exec($sql);

        /* Get files, copy them and then insert into file table. */
        $files = $this->getFilesByPackageINI($pkgINI);
        foreach($files as $file)
        {
            /* Copy files. */
            if(!$onlyDB) echo `cp --parents -av $file $this->installDir`;

            /* Get mode and md5, save into table. */
            $path  = ltrim($file, '.');
            $mode  = substr(sprintf('%o', fileperms($file)), -4);
            $owner = fileowner($file);
            $md5   = md5_file($file);
            $sql   = "INSERT INTO file (`package`, `version`, `path`, `owner`, `mode`, `md5`) 
                      VALUES('$package->name', '$package->version', '$path', '$owner', '$mode', '$md5')";
            $this->dbh->exec($sql);
        }

        /* Remove the temp directoy. */
        echo `rm -fr $temp`;

        /* Excute the postinstall shell. */
        if($package->postinstall) echo `$package->postinstall`;
    }

    /**
     * Parse package ini file.
     * 
     * @param  string    $iniFile 
     * @access public
     * @return void
     */
    public function parsePackageINI($iniFile)
    {
        $ini = new stdclass();

        /* Explode lines by [ to get every section. */
        $lines = file_get_contents($iniFile);
        $lines = ltrim($lines, '[');    // Remove the first [ to make sure explode successful.
        $sections = explode("[", $lines);

        /* Process every section. */
        foreach($sections as $section)
        {
            /* Explode section by ] to get section name and content. */
            list($sectionName, $sectionContent)  = explode("]", $section);

            $sectionName = trim($sectionName);
            if($sectionName == 'basic')
            {
                $sectionContent = parse_ini_string(trim($sectionContent));
                foreach($sectionContent as $key => $value) $ini->$key = trim($value);
            }
            else
            {
                $ini->$sectionName = trim($sectionContent);
            }
        }

        /* Set keys undefined in the ini file. */
        $keys = array('maintainer', 'preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'depends');
        foreach($keys as $key) if(!isset($ini->$key)) $ini->$key = '';

        /* Set the mode's default. */
        if(empty($ini->mode)) $ini->mode = 'full';

        return $ini;
    }

    /**
     * Browse packages.
     * 
     * @param  string    $key 
     * @access public
     * @return void
     */
    public function browse($key)
    {
        if(is_array($key)) $key = join('', $key);
        if(!empty($key)) $sql = "SELECT * FROM package WHERE name LIKE '%$key%' ORDER BY name";
        if(empty($key))  $sql = "SELECT * FROM package ORDER BY name";

        $packages   = array();
        $nameMax    = 0;
        $versionMax = 0;
        $result = $this->dbh->query($sql);
        while($package = $result->fetchArray())
        {
            $packages[] = $package;

            /* Compute the max length of name and version. */
            $nameLength    = strlen($package['name']);
            $versionLength = strlen($package['version']);
            if($nameLength > $nameMax) $nameMax = $nameLength;
            if($versionLength > $versionMax) $versionMax = $versionLength;
        }

        /* Get screen width. */
        $screenWidth = `tput cols`;
        if(!$screenWidth) $screenWidth = 100;

        /* Print packages. */
        foreach($packages as $package)
        {
            $shortDesc  = substr($package['desc'], 0, $screenWidth - $nameMax - $versionMax - 15);
            $shortDesc .= $shortDesc != $package['desc'] ? ' ...' : '';
            echo str_pad($package['name'], $nameMax) . ' ' . str_pad($package['version'], $versionMax) . ' ' . $package['mode'] . ' ' . "$shortDesc\n";
        }
    }

    /**
     * View a package.
     * 
     * @param  string    $package 
     * @access public
     * @return void
     */
    public function view($package)
    {
        if(empty($package)) die("Please input the package name.\n");

        /* Get package info. */
        $sql     = "SELECT * FROM package WHERE name = '$package'";
        $result  = $this->dbh->query($sql);
        $package = $result->fetchArray();
        echo "Name:$package[name]\nVersion:$package[version]\nMode:$package[mode]\nDesc:$package[desc]\nMaintainer:$package[maintainer]\n\n";

        /* Get files. */
        $files  = array();
        $max    = 0;
        $sql    = "SELECT * FROM file WHERE package = '$package[name]'";
        $result = $this->dbh->query($sql);
        while($file = $result->fetchArray())
        {
            $files[] = $file;

            /* Compute the max length of file name. */
            $length = strlen($file['path']);
            if($length > $max) $max = $length;

        }

        /* Print files. */
        echo str_pad('file', $max) . "\towner\tmode\tmd5\n";
        foreach($files as $file)
        {
            echo str_pad($file['path'], $max) . "\t$file[owner]\t$file[mode]\t$file[md5]\n";
        }
    }

    /**
     * Remove package.
     * 
     * @param  string    $package 
     * @access public
     * @return void
     */
    public function remove($package)
    {
        if(empty($package)) die("Please input the package name.\n");
        echo "Are you sure to remove the package $package?(y/n)";
        if(trim(fgets(STDIN)) == 'n') die();

        $dirs   = array();
        $sql    = "SELECT * FROM file WHERE package = '$package'";
        $result = $this->dbh->query($sql);
        while($file = $result->fetchArray())
        {
            $installedFile = $this->installDir . $file['path'];
            if(!file_exists($installedFile)) continue;

            /* Backup modified files. */
            $md5 = md5_file($installedFile); 
            if(!is_link($installedFile) and $md5 != $file['md5'])
            {
                echo `mv -v $installedFile $installedFile.bak`;
                continue;
            }

            echo `rm -fv $installedFile`;           // Remove files.
            $dirs[dirname($installedFile)] = '';    // Store the directories.
        }

        /* Try to remove empty directories. */
        $dirs = array_keys($dirs);
        rsort($dirs);
        foreach($dirs as $dir) $this->rmdir($dir);

        /* Delete database records. */
        $sql    = "DELETE FROM package WHERE name = '$package';";
        $sql   .= "DELETE FROM file WHERE package = '$package';";
        $result = $this->dbh->exec($sql);

        echo "Package $package removed.\n";
    }

    /**
     * Remove a dir recursively.
     * 
     * @param  string    $path 
     * @access public
     * @return void
     */
    public function rmdir($path)
    {
        if(!@rmdir($path)) return false;
        return $this->rmdir(dirname($path));
    }

    /**
     * Parse depends.
     * 
     * @param  string    $depends 
     * @access public
     * @return void
     */
    public function parseDepends($depends)
    {
    }


    /**
     * Print the help info.
     * 
     * @access public
     * @return void
     */
    public function help()
    {
        echo <<<EOT
pkg is the package manger for zentaomos.

 -l   --list   list    list all packages.
 -v   --view   view    view a package.
 -r   --remove remove  remove a package.

EOT;
    }
 
    /**
     * Get options from cmd.
     * 
     * @access public
     * @return void
     */
    public function getOptions()
    {
        global $argc, $argv;

        $mode   = 'help';
        $params = array();

        if($argc > 1)
        {
            switch($argv[1])
            {  
                           case '--init':    case 'init':     $mode  = 'init';     break;
                case '-i': case '--install': case 'install':  $mode  = 'install';  break;
                case '-r': case '--remove':  case 'remove':   $mode  = 'remove';   break;
                case '-l': case '--list':    case 'list':     $mode  = 'browse';   break;
                case '-v': case '--view':    case 'view':     $mode  = 'view';     break;
                case '-h': case '--help':    case 'help':     $mode  = 'help';     break;
                case '-w': case '--watch':   case 'watch':    $mode  = 'watch';    break;
                case '-s': case '--savelog': case 'savelog':  $mode  = 'savelog';  break;
                case '-b': case '--build':   case 'build':    $mode  = 'build';    break;
            }

            $params = array_slice($argv, 2);
        }

        $this->mode   = $mode;
        $this->params = count($params) == 1 ? $params[0] : $params;
    }
}
