# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import json
import logging
from pathlib import Path
import pytest
from pytest import MonkeyPatch, TempPathFactory
from secrets import token_hex
import shutil
import subprocess
from typing import Any, Callable, Tuple
from unittest.mock import Mock

from cylc.flow.install_plugins.log_vc_info import (
    INFO_FILENAME,
    VCSNotInstalledError,
    _get_git_commit,
    _run_cmd,
    get_status,
    get_vc_info,
    main,
    write_diff,
)
from cylc.flow.workflow_files import WorkflowFiles

Fixture = Any


BASIC_FLOW_1 = """
[scheduling]
    [[graph]]
        R1 = foo
"""

BASIC_FLOW_2 = """
[scheduling]
    [[graph]]
        R1 = bar
"""


require_git = pytest.mark.skipif(
    shutil.which('git') is None,
    reason="git is not installed"
)

require_svn = pytest.mark.skipif(
    shutil.which('svn') is None,
    reason="svn is not installed"
)


@pytest.fixture(scope='module')
def git_source_repo(tmp_path_factory: TempPathFactory) -> Tuple[str, str]:
    """Init a git repo for a workflow source dir.

    The repo has uncommitted changes. This dir is reused
    by all tests requesting it in this module.

    Returns (source_dir_path, commit_hash)
    """
    source_dir: Path = tmp_path_factory.getbasetemp() / 'git_repo'
    source_dir.mkdir()
    subprocess.run(['git', 'init'], cwd=source_dir, check=True)
    flow_file = source_dir / 'flow.cylc'
    flow_file.write_text(BASIC_FLOW_1)
    subprocess.run(['git', 'add', '-A'], cwd=source_dir, check=True)
    subprocess.run(
        ['git', 'commit', '-am', '"Initial commit"'],
        cwd=source_dir, check=True, capture_output=True)
    # Overwrite file to introduce uncommitted changes:
    flow_file.write_text(BASIC_FLOW_2)
    # Also add new file:
    (source_dir / 'gandalf.md').touch()
    commit_sha = subprocess.run(
        ['git', 'rev-parse', 'HEAD'],
        cwd=source_dir, check=True, capture_output=True, text=True
    ).stdout.splitlines()[0]
    return (str(source_dir), commit_sha)


@pytest.fixture(scope='module')
def svn_source_repo(tmp_path_factory: TempPathFactory) -> Tuple[str, str, str]:
    """Init an svn repo & working copy for a workflow source dir.

    The working copy has a flow.cylc file with uncommitted changes. This dir
    is reused by all tests requesting it in this module.

    Returns (source_dir_path, repository_UUID, repository_path)
    """
    tmp_path: Path = tmp_path_factory.getbasetemp()
    repo = tmp_path.joinpath('svn_repo')
    subprocess.run(
        ['svnadmin', 'create', 'svn_repo'], cwd=tmp_path, check=True)
    uuid = subprocess.run(
        ['svnlook', 'uuid', repo],
        check=True, capture_output=True, text=True
    ).stdout.splitlines()[0]
    project_dir = tmp_path.joinpath('project')
    project_dir.mkdir()
    project_dir.joinpath('flow.cylc').write_text(BASIC_FLOW_1)
    subprocess.run(
        ['svn', 'import', project_dir, f'file://{repo}/project/trunk',
         '-m', '"Initial import"'], check=True)
    source_dir = tmp_path.joinpath('svn_working_copy')
    subprocess.run(
        ['svn', 'checkout', f'file://{repo}/project/trunk', source_dir],
        check=True)

    flow_file = source_dir.joinpath('flow.cylc')
    # Overwrite file to introduce uncommitted changes:
    flow_file.write_text(BASIC_FLOW_2)

    return (str(source_dir), uuid, str(repo))


@require_git
def test_get_git_commit(git_source_repo: Tuple[str, str]):
    """Test get_git_commit()"""
    source_dir, commit_sha = git_source_repo
    assert _get_git_commit(source_dir) == commit_sha


@require_git
def test_get_status_git(git_source_repo: Tuple[str, str]):
    """Test get_status() for a git repo"""
    source_dir, commit_sha = git_source_repo
    assert get_status('git', source_dir) == [
        " M flow.cylc",
        "?? gandalf.md"
    ]


@require_git
def test_get_vc_info_git(git_source_repo: Tuple[str, str]):
    """Test get_vc_info() for a git repo"""
    source_dir, commit_sha = git_source_repo
    vc_info = get_vc_info(source_dir)
    assert vc_info is not None
    expected = [
        ('version control system', "git"),
        ('repository version', f"{commit_sha[:7]}-dirty"),
        ('commit', commit_sha),
        ('working copy root path', source_dir),
        ('status', [
            " M flow.cylc",
            "?? gandalf.md"
        ])
    ]
    assert list(vc_info.items()) == expected


@require_git
def test_write_diff_git(git_source_repo: Tuple[str, str], tmp_path: Path):
    """Test write_diff() for a git repo"""
    source_dir, _ = git_source_repo
    run_dir = tmp_path / 'run_dir'
    (run_dir / WorkflowFiles.LogDir.DIRNAME).mkdir(parents=True)
    diff_file = write_diff('git', source_dir, run_dir)
    diff_lines = diff_file.read_text().splitlines()
    assert diff_lines[0].startswith("# Auto-generated diff")
    for line in ("diff --git a/flow.cylc b/flow.cylc",
                 "-        R1 = foo",
                 "+        R1 = bar"):
        assert line in diff_lines


@require_git
def test_main_git(git_source_repo: Tuple[str, str], tmp_run_dir: Callable):
    """Test the written JSON info file."""
    source_dir, _ = git_source_repo
    run_dir: Path = tmp_run_dir('frodo')
    main(source_dir, None, run_dir)
    with open(
        run_dir
        / WorkflowFiles.LogDir.DIRNAME
        / WorkflowFiles.LogDir.VERSION
        / INFO_FILENAME, 'r'
    ) as f:
        loaded = json.loads(f.read())
    assert isinstance(loaded, dict)
    assert loaded['version control system'] == 'git'
    assert isinstance(loaded['status'], list)
    assert len(loaded['status']) == 2


@require_svn
def test_get_vc_info_svn(svn_source_repo: Tuple[str, str, str]):
    """Test get_vc_info() for an svn working copy"""
    source_dir, uuid, repo_path = svn_source_repo
    vc_info = get_vc_info(source_dir)
    assert vc_info is not None
    expected = [
        ('version control system', "svn"),
        ('working copy root path', str(source_dir)),
        ('url', f"file://{repo_path}/project/trunk"),
        ('repository uuid', uuid),
        ('revision', "1"),
        ('status', ["M       flow.cylc"])
    ]
    assert list(vc_info.items()) == expected


@require_svn
def test_write_diff_svn(svn_source_repo: Tuple[str, str, str], tmp_path: Path):
    """Test write_diff() for an svn working copy"""
    source_dir, _, _ = svn_source_repo
    run_dir = tmp_path / 'run_dir'
    (run_dir / WorkflowFiles.LogDir.DIRNAME).mkdir(parents=True)
    diff_file = write_diff('svn', source_dir, run_dir)
    diff_lines = diff_file.read_text().splitlines()
    assert diff_lines[0].startswith("# Auto-generated diff")
    for line in (f"--- {source_dir}/flow.cylc	(revision 1)",
                 f"+++ {source_dir}/flow.cylc	(working copy)",
                 "-        R1 = foo",
                 "+        R1 = bar"):
        assert line in diff_lines


def test_not_repo(tmp_path: Path, monkeypatch: MonkeyPatch):
    """Test get_vc_info() and main() for a dir that is not a supported repo"""
    source_dir = Path(tmp_path, 'git_repo')
    source_dir.mkdir()
    flow_file = source_dir.joinpath('flow.cylc')
    flow_file.write_text(BASIC_FLOW_1)
    mock_write_vc_info = Mock()
    monkeypatch.setattr('cylc.flow.install_plugins.log_vc_info.write_vc_info',
                        mock_write_vc_info)
    mock_write_diff = Mock()
    monkeypatch.setattr('cylc.flow.install_plugins.log_vc_info.write_diff',
                        mock_write_diff)

    assert get_vc_info(source_dir) is None
    assert main(source_dir, None, None) is False  # type: ignore
    assert mock_write_vc_info.called is False
    assert mock_write_diff.called is False


@require_git
def test_no_base_commit_git(tmp_path: Path):
    """Test get_vc_info() and write_diff() for a recently init'd git source dir
    that does not have a base commit yet."""
    source_dir = Path(tmp_path, 'new_git_repo')
    source_dir.mkdir()
    subprocess.run(['git', 'init'], cwd=source_dir, check=True)
    flow_file = source_dir.joinpath('flow.cylc')
    flow_file.write_text(BASIC_FLOW_1)
    run_dir = tmp_path / 'run_dir'
    (run_dir / WorkflowFiles.LogDir.DIRNAME).mkdir(parents=True)

    vc_info = get_vc_info(source_dir)
    assert vc_info is not None
    assert list(vc_info.items()) == [
        ('version control system', "git"),
        ('working copy root path', str(source_dir)),
        ('status', ["?? flow.cylc"])
    ]

    # Diff file expected to be empty (only containing comment lines),
    # but should work without raising
    diff_file = write_diff('git', source_dir, run_dir)
    for line in diff_file.read_text().splitlines():
        assert line.startswith('#')


@require_svn
def test_untracked_svn_subdir(
    svn_source_repo: Tuple[str, str, str], log_filter
):
    repo_dir, *_ = svn_source_repo
    source_dir = Path(repo_dir, 'jar_jar_binks')
    source_dir.mkdir()
    assert get_vc_info(source_dir) is None
    assert log_filter(logging.WARNING, contains="$ svn info")


def test_not_installed(
    tmp_path,
    monkeypatch: pytest.MonkeyPatch,
    caplog: pytest.LogCaptureFixture,
    log_filter,
):
    """Test what happens if version control software is not installed."""
    fake_vcs = token_hex(8)
    with pytest.raises(VCSNotInstalledError):
        _run_cmd(fake_vcs, [], cwd=tmp_path)

    monkeypatch.setattr(
        'cylc.flow.install_plugins.log_vc_info.INFO_COMMANDS',
        {fake_vcs: []}
    )
    caplog.set_level(logging.DEBUG)
    assert get_vc_info(tmp_path) is None
    assert log_filter(
        logging.DEBUG,
        contains=f"{fake_vcs} does not appear to be installed",
    )
