<?php

/**
 * Script to get changes between feature branch and the mainline
 *
 * @category   dev
 * @package    build
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

define(
    'USAGE',
    <<<USAGE
        php -f get_github_changes.php --
    --output-file="<output_file>"
    --base-path="<base_path>"
    --repo="<main_repo>"
    --branch="<branch>"
    [--file-extensions="<comma_separated_list_of_formats>"]

USAGE
);

$options = getopt('', ['output-file:', 'base-path:', 'repo:', 'file-extensions:', 'branch:']);

$requiredOptions = ['output-file', 'base-path', 'repo', 'branch'];
if (!validateInput($options, $requiredOptions)) {
    echo USAGE;
    exit(1);
}

$fileExtensions = explode(',', isset($options['file-extensions']) ? $options['file-extensions'] : 'php');

include_once __DIR__ . '/framework/autoload.php';

$mainline = 'mainline_' . (string)rand(0, 9999);
$repo = getRepo($options, $mainline);
$branches = $repo->getBranches('--remotes');
generateBranchesList($options['output-file'], $branches, $options['branch']);
$changes = retrieveChangesAcrossForks($mainline, $repo, $options['branch']);
$changedFiles = getChangedFiles($changes, $fileExtensions);
generateChangedFilesList($options['output-file'], $changedFiles);
saveChangedFileContent($repo);

$additions = retrieveNewFilesAcrossForks($mainline, $repo, $options['branch']);
$addedFiles = getChangedFiles($additions, $fileExtensions);
$additionsFile = pathinfo($options['output-file']);
$additionsFile = $additionsFile['dirname']
    . DIRECTORY_SEPARATOR
    . $additionsFile['filename']
    . '.added.'
    . $additionsFile['extension'];
generateChangedFilesList($additionsFile, $addedFiles);

cleanup($repo, $mainline);

/**
 * Save changed file content.
 *
 * @param GitRepo $repo
 * @return void
 */
function saveChangedFileContent(GitRepo $repo)
{
    $changedFilesContentFileName = BP . Magento\TestFramework\Utility\ChangedFiles::CHANGED_FILES_CONTENT_FILE;
    foreach ($repo->getChangedContentFiles() as $key => $changedContentFile) {
        $filePath = sprintf($changedFilesContentFileName, $key);
        $oldContent = file_exists($filePath) ? file_get_contents($filePath) : '{}';
        $oldData = json_decode($oldContent, true);
        $data = array_merge($oldData, $changedContentFile);
        file_put_contents($filePath, json_encode($data));
    }
}

/**
 * Generates a file containing changed files
 *
 * @param string $outputFile
 * @param array $changedFiles
 * @return void
 */
function generateChangedFilesList($outputFile, $changedFiles)
{
    $changedFilesList = fopen($outputFile, 'w');
    foreach ($changedFiles as $file) {
        fwrite($changedFilesList, $file . PHP_EOL);
    }
    fclose($changedFilesList);
}

/**
 * Generates a file containing origin branches
 *
 * @param string $outputFile
 * @param array $branches
 * @param string $branchName
 * @return void
 */
function generateBranchesList($outputFile, $branches, $branchName)
{
    $branchOutputFile = str_replace('changed_files', 'branches', $outputFile);
    $branchesList = fopen($branchOutputFile, 'w');
    fwrite($branchesList, $branchName . PHP_EOL);
    foreach ($branches as $branch) {
        fwrite($branchesList, substr(strrchr($branch, '/'), 1) . PHP_EOL);
    }
    fclose($branchesList);
}

/**
 * Gets list of changed files
 *
 * @param array $changes
 * @param array $fileExtensions
 * @return array
 */
function getChangedFiles(array $changes, array $fileExtensions)
{
    $files = [];
    foreach ($changes as $fileName) {
        foreach ($fileExtensions as $extensions) {
            $isFileExension = strpos($fileName, '.' . $extensions);
            if ($isFileExension) {
                $files[] = $fileName;
            }
        }
    }

    return $files;
}

/**
 * Retrieves changes across forks
 *
 * @param array $options
 * @param string $mainline
 * @return GitRepo
 * @throws Exception
 */
function getRepo($options, $mainline)
{
    $repo = new GitRepo($options['base-path']);
    $repo->addRemote($mainline, $options['repo']);
    $repo->fetch($mainline);
    return $repo;
}

/**
 * Combine list of changed files based on comparison between forks.
 *
 * @param string $mainline
 * @param GitRepo $repo
 * @param string $branchName
 * @return array
 */
function retrieveChangesAcrossForks($mainline, GitRepo $repo, $branchName)
{
    return $repo->compareChanges($mainline, $branchName, GitRepo::CHANGE_TYPE_ALL);
}

/**
 * Combine list of new files based on comparison between forks.
 *
 * @param string $mainline
 * @param GitRepo $repo
 * @param string $branchName
 * @return array
 */
function retrieveNewFilesAcrossForks($mainline, GitRepo $repo, $branchName)
{
    return $repo->compareChanges($mainline, $branchName, GitRepo::CHANGE_TYPE_ADDED);
}

/**
 * Deletes temporary "base" repo
 *
 * @param GitRepo $repo
 * @param string $mainline
 */
function cleanup($repo, $mainline)
{
    $repo->removeRemote($mainline);
}

/**
 * Validates input options based on required options
 *
 * @param array $options
 * @param array $requiredOptions
 * @return bool
 */
function validateInput(array $options, array $requiredOptions)
{
    foreach ($requiredOptions as $requiredOption) {
        if (!isset($options[$requiredOption]) || empty($options[$requiredOption])) {
            return false;
        }
    }
    return true;
}

//@codingStandardsIgnoreStart
class GitRepo
// @codingStandardsIgnoreEnd
{
    const CHANGE_TYPE_ADDED = 1;
    const CHANGE_TYPE_MODIFIED = 2;
    const CHANGE_TYPE_ALL = 3;

    /**
     * Absolute path to git project
     *
     * @var string
     */
    private $workTree;

    /**
     * @var array
     */
    private $remoteList = [];

    /**
     * Array of changed content files.
     *
     * Example:
     *         'extension' =>
     *                      'path_to_file/filename'  => 'Content that was edited',
     *                      'path_to_file/filename2' => 'Content that was edited',
     *
     * @var array
     */
    private $changedContentFiles = [];

    /**
     * @param string $workTree absolute path to git project
     */
    public function __construct($workTree)
    {
        if (empty($workTree) || !is_dir($workTree)) {
            throw new UnexpectedValueException('Working tree should be a valid path to directory');
        }
        $this->workTree = $workTree;
    }

    /**
     * Adds remote
     *
     * @param string $alias
     * @param string $url
     */
    public function addRemote($alias, $url)
    {
        if (isset($this->remoteList[$alias])) {
            return;
        }
        $this->remoteList[$alias] = $url;

        $this->call(sprintf('remote add %s %s', $alias, $url));
    }

    /**
     * Remove remote
     *
     * @param string $alias
     */
    public function removeRemote($alias)
    {
        if (isset($this->remoteList[$alias])) {
            $this->call(sprintf('remote rm %s', $alias));
            unset($this->remoteList[$alias]);
        }
    }

    /**
     * Fetches remote
     *
     * @param string $remoteAlias
     */
    public function fetch($remoteAlias)
    {
        if (!isset($this->remoteList[$remoteAlias])) {
            throw new LogicException('Alias "' . $remoteAlias . '" is not defined');
        }

        $this->call(sprintf('fetch %s', $remoteAlias));
    }

    /**
     * Returns branches
     *
     * @param string $source
     * @return array|mixed
     */
    public function getBranches($source = '--all')
    {
        $result = $this->call(sprintf('branch ' . $source));

        return is_array($result) ? $result : [];
    }

    /**
     * Returns files changes between branch and HEAD
     *
     * @param string $remoteAlias
     * @param string $remoteBranch
     * @param int $changesType
     * @return array
     */
    public function compareChanges($remoteAlias, $remoteBranch, $changesType = self::CHANGE_TYPE_ALL)
    {
        if (!isset($this->remoteList[$remoteAlias])) {
            throw new LogicException('Alias "' . $remoteAlias . '" is not defined');
        }

        $result = $this->call(sprintf('log %s/%s..HEAD  --name-status --oneline', $remoteAlias, $remoteBranch));

        return is_array($result)
            ? $this->filterChangedFiles(
                $result,
                $remoteAlias,
                $remoteBranch,
                $changesType
            )
            : [];
    }

    /**
     * Makes a diff of file for specified remote/branch and filters only those have real changes
     *
     * @param array $changes
     * @param string $remoteAlias
     * @param string $remoteBranch
     * @param int $changesType
     * @return array
     */
    protected function filterChangedFiles(
        array $changes,
        $remoteAlias,
        $remoteBranch,
        $changesType = self::CHANGE_TYPE_ALL
    ) {
        $countScannedFiles = 0;
        $changedFilesMasks = $this->buildChangedFilesMask($changesType);
        $filteredChanges = [];
        foreach ($changes as $fileName) {
            $countScannedFiles++;
            if (($countScannedFiles % 5000) == 0) {
                echo $countScannedFiles . " files scanned so far\n";
            }

            $changeTypeMask = $this->detectChangeTypeMask($fileName, $changedFilesMasks);
            if (null === $changeTypeMask) {
                continue;
            }

            $fileName = trim(substr($fileName, strlen($changeTypeMask)));
            if (in_array($fileName, $filteredChanges)) {
                continue;
            }

            $fileChanges = $this->getFileChangeDetails($fileName, $remoteAlias, $remoteBranch);
            if (empty($fileChanges)) {
                continue;
            }

            if (!(isset($this->changedContentFiles[$fileName]))) {
                $this->setChangedContentFile($fileChanges, $fileName);
            }
            $filteredChanges[] = $fileName;
        }
        echo $countScannedFiles . " files scanned\n";

        return $filteredChanges;
    }

    /**
     * Build mask of git diff report
     *
     * @param int $changesType
     * @return array
     */
    private function buildChangedFilesMask(int $changesType): array
    {
        $changedFilesMasks = [];
        foreach ([
            self::CHANGE_TYPE_ADDED => "A\t",
            self::CHANGE_TYPE_MODIFIED => "M\t",
        ] as $changeType => $changedFilesMask) {
            if ($changeType & $changesType) {
                $changedFilesMasks[] = $changedFilesMask;
            }
        }
        return $changedFilesMasks;
    }

    /**
     * Find one of the allowed modification mask returned by git diff.
     *
     * Example of change record: "A path/to/added_file"
     *
     * @param string $changeRecord
     * @param array $allowedMasks
     * @return string|null
     */
    private function detectChangeTypeMask(string $changeRecord, array $allowedMasks)
    {
        foreach ($allowedMasks as $mask) {
            if (strpos($changeRecord, $mask) === 0) {
                return $mask;
            }
        }
        return null;
    }

    /**
     * Read detailed information about changes in a file
     *
     * @param string $fileName
     * @param string $remoteAlias
     * @param string $remoteBranch
     * @return array
     */
    private function getFileChangeDetails(string $fileName, string $remoteAlias, string $remoteBranch): array
    {
        if (!is_file($this->workTree . '/' . $fileName)) {
            return [];
        }

        $result = $this->call(
            sprintf(
                'diff HEAD %s/%s -- %s',
                $remoteAlias,
                $remoteBranch,
                $fileName
            )
        );

        return $result;
    }

    /**
     * Set changed content for file.
     *
     * @param array $content
     * @param string $fileName
     * @return void
     */
    private function setChangedContentFile(array $content, $fileName)
    {
        $changedContent = '';
        $extension = Magento\TestFramework\Utility\ChangedFiles::getFileExtension($fileName);

        foreach ($content as $item) {
            if (strpos($item, '---') !== 0 && strpos($item, '-') === 0 && $line = ltrim($item, '-')) {
                $changedContent .= $line . "\n";
            }
        }
        if ($changedContent !== '') {
            $this->changedContentFiles[$extension][$fileName] = $changedContent;
        }
    }

    /**
     * Get changed content files collection.
     *
     * @return array
     */
    public function getChangedContentFiles()
    {
        return $this->changedContentFiles;
    }

    /**
     * Makes call ro git cli
     *
     * @param string $command
     * @return mixed
     */
    private function call($command)
    {
        $gitCmd = sprintf(
            'git --git-dir %s --work-tree %s',
            escapeshellarg("{$this->workTree}/.git"),
            escapeshellarg($this->workTree)
        );
        $tmp = sprintf('%s %s', $gitCmd, $command);
        exec($tmp, $output);
        return $output;
    }
}
