<?php
/**
 * A bundle may use one or more text domains, and may or may not physically house them. 
 * Essentially a bundle "uses" a text domain.
 * Types are "theme", "plugin" and "core" 
 */
abstract class Loco_package_Bundle extends ArrayObject implements JsonSerializable {

    /**
     * Internal handle for targeting in WordPress, e.g. "twentyfifteen" or "loco-translate/loco.php"
     * @var string
     */
    private $handle;

    /**
     * Short name, e.g. "twentyfifteen" or "loco-translate"
     * @var string
     */
    private $slug;

    /**
     * Friendly name, e.g. "Twenty Fifteen
     * @var string
     */
    private $name;

    /**
     * Full path to root directory of bundle
     * @var Loco_fs_Directory
     */
    private $root;

    /**
     * Directory paths to exclude from all projects
     * @var Loco_fs_FileList
     */
    private $xpaths;

    /**
     * Full path to PHP bootstrap file
     * @var string
     */
    private $boot;

    /**
     * Whether bundle is a single file, as opposed to in its own directory
     * @var bool
     */
    protected $solo;

    /**
     * Method with which bundle has been configured
     * @var string|false (file|db|meta|internal)
     */
    private $saved = false;

    /**
     * Get system (i.e. "global") target locations for all projects of this type.
     * These are always append to configs, and always excluded from serialization
     * @return string[] absolute directory paths
     */
    abstract public function getSystemTargets();

    /**
     * Get canonical info registered with WordPress, i.e. plugin or theme headers
     * @return Loco_package_Header
     */
    abstract public function getHeaderInfo();

    /**
     * Get built-in translatable values mapped to annotation for translators
     * @return array 
     */
    abstract public function getMetaTranslatable();

    /**
     * Get type of Bundle (title case)
     * @return string
     */
    abstract public function getType();

    /**
     * Get absolute URL to bundle root, with trailing slash
     * @return string
     */
    abstract public function getDirectoryUrl();


    /**
     * Construct bundle from unique ID containing type and handle
     * @return self
     */
    public static function fromId( $id ){
        $r = explode( '.', $id, 2 );
        return self::createType( $r[0], isset($r[1]) ? $r[1] : '' );
    }

    
    /**
     * @param string $type
     * @param string $handle
     * @return self
     * @throws Loco_error_Exception
     */
    public static function createType( $type, $handle ){
        $func = [ 'Loco_package_'.ucfirst($type), 'create' ];
        if( is_callable($func) ){
            $bundle = call_user_func( $func, $handle );
        }
        else {
            throw new Loco_error_Exception('Unexpected bundle type: '.$type );
        }  
        return $bundle;
    }


    /**
     * Resolve a file path to a plugin, theme or the core 
     * @return self|null
     */
    public static function fromFile( Loco_fs_File $file ){
        if( $file->underThemeDirectory() ){
            return Loco_package_Theme::fromFile($file);
        }
        else if( $file->underPluginDirectory() ){
            return Loco_package_Plugin::fromFile($file);
        }
        else if( $file->underWordPressDirectory() && ! $file->underContentDirectory() ){
            return Loco_package_Core::create();
        }
        else {
            return null;
        }
    }


    /**
     * Construct from WordPress handle and friendly name
     * @param string $handle
     * @param string $name
     */
    public function __construct( $handle, $name ){
        parent::__construct();
        $this->setHandle($handle)->setName($name);
        $this->xpaths = new Loco_fs_FileList;
    }


    /**
     * Re-fetch this bundle from its currently saved location
     * @return self
     */
    public function reload(){
        return call_user_func( [ get_class($this), 'create' ], $this->getSlug() );
    }


    /**
     * Get ID that uniquely identifies bundle by its type and handle
     * @return string
     */
    public function getId(){
        $type = strtolower( $this->getType() );
        return $type.'.'.$this->getHandle();
    }



    /**
     * @return string
     */
    public function __toString(){
        return $this->name;
    }


    /**
     * @return bool
     */
    public function isTheme(){
        return false;
    }


    /**
     * Get parent bundle if possible. This can only be a theme.
     * @codeCoverageIgnore
     * @return Loco_package_Theme|null
     */
    public function getParent(){
        trigger_error( $this->getType().' bundles cannot have parents. Check isTheme first');
        return null;
    }


    /**
     * @return bool
     */
    public function isPlugin(){
        return false;
    }


    /**
     * Get handle of bundle unique for its type, e.g. "twentyfifteen" or "loco-translate/loco.php"
     * @return string
     */
    public function getHandle(){
        return $this->handle;
    }


    /**
     * Attempt to get the vendor-specific slug, which may or may not be the same as the internal handle
     * @return string
     */
    public function getSlug(){
        if( $slug = $this->slug ){
            return $slug;
        }
        // fall back to runtime handle
        return $this->getHandle();
    }


    /**
     * Set friendly name of bundle
     * @return self
     */
    public function setName( $name ){
        $this->name = (string) $name;
        return $this;
    }


    /**
     * Set short name of bundle which may or may not match unique handle
     * @return self
     */
    public function setSlug( $slug ){
        $this->slug = (string) $slug;
        return $this;
    }


    /**
     * Set internal handle registered with WordPress for this bundle type
     * @return self
     */
    public function setHandle( $handle ){
        $this->handle = (string) $handle;
        return $this;
    }


    /**
     * Get friendly name of bundle, e.g. "Twenty Fifteen" or "Loco Translate"
     * @return string
     */
    public function getName(){
        return $this->name;
    }
    
    
    /**
     * Whether bundle root is currently known
     * @return bool
     */
    public function hasDirectoryPath(){
        return (bool) $this->root;
    }    


    /**
     * Set root directory for bundle. e.g. theme or plugin directory
     * @return self
     */
    public function setDirectoryPath( $path ){
        $this->root = new Loco_fs_Directory( $path );
        $this->root->normalize();
        return $this;
    }


    /**
     * Get absolute path to root directory for bundle. e.g. theme or plugin directory
     * @return string
     */
    public function getDirectoryPath(){
        if( $this->root ){
            return $this->root->getPath();
        }
        // without a root directory return WordPress root
        return untrailingslashit(ABSPATH);
    }


    /**
     * @return string[]
     */
    public function getVendorRoots(){
        $dirs = [];
        $base = $this->getDirectoryPath();
        foreach( ['node_modules','vendor'] as $f ){
            $path = $base.'/'.$f;
            if( Loco_fs_File::is_readable($path) && is_dir($path) ){
                $dirs[] = $path;
            }
        }
        return $dirs;
    }


    /**
     * Get file locations to exclude from all projects in bundle. These are effectively "hidden"
     * @return Loco_fs_FileList
     */
    public function getExcludedLocations(){
        return $this->xpaths;
    }


    /**
     * Add a path for excluding from all projects
     * @param Loco_fs_File|string $path
     * @return Loco_package_Bundle
     */
    public function excludeLocation( $path ){
        $this->xpaths->add( new Loco_fs_File($path) );
        return $this;
    }


    /**
     * Create a file searcher from root location, excluding that which is excluded
     * @return Loco_fs_FileFinder
     */
    public function getFileFinder(){
        $root = $this->getDirectoryPath();
        /*/ if bundle is symlinked it's resource files won't be matched properly
        if( is_link($root) && ( $real = realpath($root) ) ){
            $root = $real;
        }*/
        $finder = new Loco_fs_FileFinder( $root );
        foreach( $this->xpaths as $path ){
            $finder->exclude( (string) $path );
        }
        return $finder;
    }


    /**
     * Get primary PHP source file containing bundle bootstrap code, if applicable
     * @return string
     */
    public function getBootstrapPath(){
        return $this->boot;
    }


    /**
     * Set primary PHP source file containing bundle bootstrap code, if applicable.
     * @param string $path to PHP file
     * @return Loco_package_Bundle
     */
    public function setBootstrapPath( $path ){
        $path = (string) $path;
        // sanity check this is a PHP file even if it doesn't exist
        if( '.php' !== substr($path,-4) ){
            throw new Loco_error_Exception('Bootstrap file should end .php, got '.$path );
        }
        $this->boot = $path;
        // base directory can be inferred from bootstrap path
        if( ! $this->hasDirectoryPath() ){
            $this->setDirectoryPath( dirname($path) );
        }
        return $this;
    }


    /**
     * Test whether bundle consists of a single file
     */
    public function isSingleFile(){
        return $this->solo;
    }
    
    
    /**
     * Add all projects defined in a TextDomain
     * @return self
     */
    public function addDomain( Loco_package_TextDomain $domain ){
        /* @var Loco_package_Project $proj */
        foreach( $domain as $proj ){
            $this->addProject($proj);
        }
        return $this;
    }


    /**
     * Add a translation project to bundle.
     * Note that this always adds without checking uniqueness. Call hasProject first if it could be a duplicate
     * @return self
     */
    public function addProject( Loco_package_Project $project ){
        // add global targets
        foreach( $this->getSystemTargets() as $path ){
            $project->addSystemTargetDirectory( $path );
        }
        // add global exclusions affecting source and target locations
        foreach( $this->xpaths as $path ){
            $project->excludeLocation( $path );
        }
        // projects must be unique by Text Domain and "slug" (used to prefix files)
        // however, I am not indexing them here on purpose so domain and slug may be added at any time.
        $this[] = $project;
        return $this;
    }


    /**
     * Export projects grouped by domain
     * @return array indexed by Text Domain name
     */
    public function exportGrouped(){
        $domains = [];
        /* @var $proj Loco_package_Project */
        foreach( $this as $proj ){
            $domain = $proj->getDomain();
            $key = $domain->getName();
            $domains[$key][] = $proj; 
        }
        return $domains;
    }



    /**
     * Create a suitable Text Domain from bundle's name.
     * Note that internal handle may be a directory name differing entirely from the author's intention, hence the configured bundle name is slugged instead
     * @return Loco_package_TextDomain
     */
    public function createDomain(){
        $slug = sanitize_title( $this->name, $this->slug );
        return new Loco_package_TextDomain( $slug );
    }



    /**
     * Generate default configuration. 
     * Adds a simple one domain, one project config
     * @param string|null $domainName optional Text Domain to use
     * @return Loco_package_Project
     */
    public function createDefault( $domainName = null ){
        if( is_null($domainName) ){
            $domain = $this->createDomain();
        }
        else {
            $domain = new Loco_package_TextDomain($domainName);
        }
        $project = $domain->createProject( $this, $this->name );
        if( $this->solo ){
            $project->addSourceFile( $this->getBootstrapPath() );
        }
        else {
            $project->addSourceDirectory( $this->getDirectoryPath() );
        }

        $this->addProject( $project );
        return $project;
    }


    /**
     * Configure from custom saved option
     * @return bool whether configured via database option
     */
    public function configureDb(){
        $option = $this->getCustomConfig();
        if( $option instanceof  Loco_config_CustomSaved ){
            $option->configure();
            $this->saved = 'db';
            return true;
        }
        return false;
    }


    /**
     * Configure from XML config
     * @return bool whether configured via static XML file
     */
    public function configureXml(){
        $xmlfile = $this->getConfigFile();
        if( $xmlfile instanceof Loco_fs_File ){
            $reader = new Loco_config_BundleReader($this);
            $reader->loadXml( $xmlfile );
            $this->saved = 'file';
            return true;
        }
        return false;
    }


    /**
     * Get XML configuration file used to define this bundle
     * @return Loco_fs_File
     */
    public function getConfigFile(){
        $base = $this->getDirectoryPath();
        $file = new Loco_fs_File( $base.'/loco.xml' );
        if( ! $file->exists() || ! loco_check_extension('dom') ){
            return null;
        }
        return $file;
    }


    /**
     * Check whether bundle is manually configured, as opposed to guessed
     * @return string|false (file|db|meta|internal)
     */    
    public function isConfigured(){
        return $this->saved;
    }


    /**
     * Do basic configuration from bundle meta data (file headers)
     * @param array $header tags from theme or plugin bootstrap file
     * @return bool whether configured via header tags
     */
    public function configureMeta( array $header ){
        if( isset($header['Name']) ){
            $this->setName( $header['Name'] );
        }
        if( isset($header['TextDomain']) && ( $slug = $header['TextDomain'] ) ){
            $domain = new Loco_package_TextDomain($slug);
            $domain->setCanonical( true );
            // use domain as bundle handle and slug if not set when constructed
            if( ! $this->handle ){
                $this->handle = $slug;
            }
            if( ! $this->getSlug() ){
                $this->setSlug( $slug );
            }
            $project = $domain->createProject( $this, $this->name );
            // May have declared DomainPath
            $base = $this->getDirectoryPath();
            if( isset($header['DomainPath']) && ( $path = trim($header['DomainPath'],'/') ) ){
                $project->addTargetDirectory( $base.'/'.$path );
            }
            // else use standard language path if it exists
            else if( ! $this->solo ){
                if( is_dir($base.'/languages') ) {
                    $project->addTargetDirectory($base.'/languages');
                }
                // else add bundle root by default
                else {
                    $project->addTargetDirectory($base);
                }
            }
            // single file bundles can have only one source file
            if( $this->solo ){
                $project->addSourceFile( $this->getBootstrapPath() );
            }
            // else add bundle root as default source file location
            else {
                $project->addSourceDirectory( $base );
            }
            // automatically block common vendor locations
            foreach( $this->getVendorRoots() as $root ){
                $this->excludeLocation($root);
            }
            // default domain added
            $this->addProject($project);
            $this->saved = 'meta';
            return true;
        }

        return false;
    }


    /**
     * Configure bundle from canonical sources.
     * Source order is "db","file","meta" where meta is the auto-config fallback.
     * No deep scanning is performed at this point
     * @param string $base
     * @param string[] $header tags from theme or plugin bootstrap file
     * @return self
     */
    public function configure( $base, array $header ){
        $this->setDirectoryPath( $base );
        $this->configureDb() || $this->configureXml() || $this->configureMeta($header);
        do_action('loco_bundle_configured',$this);
        return $this;
    }


    /**
     * Get the custom config saved in WordPress DB for this bundle
     * @return Loco_config_CustomSaved|null
     */
    public function getCustomConfig(){
        $custom = new Loco_config_CustomSaved;
        if( $custom->setBundle($this)->fetch() ){
            return $custom;
        }
        return null;
    }


    /**
     * Inherit another bundle. Used for child themes to display parent translations
     * @return self
     */
    public function inherit( Loco_package_Bundle $parent ){
        foreach( $parent as $project ){
            if( ! $this->hasProject($project) ){
                $this->addProject( $project );
            }
        }
        return $this;
    }


    /**
     * Get unique translation project by text domain (and optionally slug)
     * TODO would prefer to avoid iteration, but slug can be changed at any time
     * @param string $domain
     * @param string|null $slug
     * @return Loco_package_Project
     */
    public function getProject( $domain, $slug = null ){
        if( is_null($slug) ){
            $slug = $domain;
        }
        /* @var $project Loco_package_Project */
        foreach( $this as $project ){
            if( $project->getSlug() === $slug && $project->getDomain()->getName() === $domain ){
                return $project;
            }
        }
        return null;
    }


    /**
     * @return Loco_package_Project|null
     */
    public function getDefaultProject(){
        $i = 0;
        /* @var Loco_package_Project $project */
        foreach( $this as $project ){
            if( $project->isDomainDefault() ){
                return $project;
            }
            $i++;
        }
        // nothing is domain default, but if we only have one, then duh
        if( 1 === $i ){
            return $project;
        }
        return null;
    }

    
    /**
     * Test if project already exists in bundle
     * @return bool
     */
    public function hasProject( Loco_package_Project $project ){
        return (bool) $this->getProject( $project->getDomain()->getName(), $project->getSlug() );
    }


    /**
     * @return Loco_package_TextDomain[]
     */
    public function getDomains(){
        $domains = [];
        /* @var $project Loco_package_Project */
        foreach( $this as $project ){
            if( $domain = $project->getDomain() ){
                $d = (string) $domain;
                if( ! isset($domains[$d]) ){
                    $domains[$d] = $domain;
                }
            }
        }
        return $domains;
    }


    /**
     * Get newest timestamp of all translation files (includes template, but exclude source files)
     * @return int
     */    
    public function getLastUpdated(){
        // recent items is a convenient cache for checking last modified times
        $t = Loco_data_RecentItems::get()->hasBundle( $this->getId() );
        // else have to scan targets across all projects
        if( 0 === $t ){
            /* @var Loco_package_Project $project */
            foreach( $this as $project ){
                $t = max( $t, $project->getLastUpdated() );
            }
        }
        return $t;
    }


    /**
     * Get project by ID
     * @param string $id identifier of the form <domain>[.<slug>]
     * @return Loco_package_Project
     */
    public function getProjectById( $id ){
        list( $domain, $slug ) = Loco_package_Project::splitId($id);
        return $this->getProject( $domain, $slug );
    }


    /**
     * Reset bundle configuration, but keep metadata like name and slug.
     * Call this before applying a saved config, otherwise values will just be added on top.
     * @return self
     */
    public function clear(){
        $this->exchangeArray( [] );
        $this->xpaths = new Loco_fs_FileList;
        $this->saved = false;
        return $this;
    }


    /**
     * @return array
     */
    #[ReturnTypeWillChange]
    public function jsonSerialize(){
        $writer = new Loco_config_BundleWriter( $this );
        return $writer->toArray();
    }


    /**
     * Create a copy of this bundle containing any files found that aren't currently configured
     * @return self
     */
    public function invert(){
        return Loco_package_Inverter::compile( $this );
    }

}