# Copyright (c) 2020 IBM Corp. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for trestle import command."""
import argparse
import json
import os
import pathlib
import random
import string
import sys
import tempfile
from json.decoder import JSONDecodeError

from _pytest.monkeypatch import MonkeyPatch

import pytest

from tests import test_utils

import trestle.common.const as const
import trestle.common.err as err
import trestle.core.commands.import_ as importcmd
import trestle.oscal
from trestle.cli import Trestle
from trestle.common import file_utils as fs
from trestle.common.model_utils import ModelUtils
from trestle.core import generators
from trestle.core.commands import create
from trestle.core.models.plans import Plan
from trestle.oscal.catalog import Catalog, Group
from trestle.oscal.profile import Modify, Profile, SetParameter


def test_import_cmd(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
    """Happy path test at the cli level."""
    # 1. Input file, profile:
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    profile_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    profile_data = generators.generate_sample_model(trestle.oscal.profile.Profile)
    profile_data.oscal_write(pathlib.Path(profile_file))
    # 2. Input file, target:
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    catalog_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    catalog_data = generators.generate_sample_model(Catalog)
    catalog_data.oscal_write(pathlib.Path(catalog_file))
    # Test 1
    test_args = f'trestle import -f {profile_file} -o imported'.split()
    monkeypatch.setattr(sys, 'argv', test_args)
    rc = Trestle().run()
    assert rc == 0
    # Test 2
    test_args = f'trestle import -f {catalog_file} -o imported'.split()
    monkeypatch.setattr(sys, 'argv', test_args)
    rc = Trestle().run()
    assert rc == 0


def test_import_profile_with_optional_added(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
    """Create profile, add modify to it, and import."""
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    profile_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    # create generic profile
    profile_data = generators.generate_sample_model(trestle.oscal.profile.Profile)
    # create special parameter and add it to profile
    set_parameter = SetParameter(param_id='my_param', depends_on='my_depends')
    modify = Modify(set_parameters=[set_parameter])
    profile_data.modify = modify
    # write it to place outside trestle directory
    profile_data.oscal_write(pathlib.Path(profile_file))
    # now do actual import into trestle directory with name 'imported'
    test_args = f'trestle import -f {profile_file} -o imported'.split()
    monkeypatch.setattr(sys, 'argv', test_args)
    rc = Trestle().run()
    assert rc == 0
    # then do a direct read of it and confirm our parameter is there
    profile_path = tmp_trestle_dir / 'profiles/imported/profile.json'
    profile: Profile = Profile.oscal_read(profile_path)
    params = profile.modify.set_parameters
    assert params
    assert len(params) == 1
    assert params[0].param_id == 'my_param'
    assert params[0].depends_on == 'my_depends'


@pytest.mark.parametrize('regen', [False, True])
def test_import_run(tmp_trestle_dir: pathlib.Path, regen: bool) -> None:
    """Test successful _run() on valid input."""
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    catalog_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    catalog_data = generators.generate_sample_model(Catalog)
    catalog_data.oscal_write(pathlib.Path(catalog_file))
    i = importcmd.ImportCmd()
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=catalog_file, output='imported', verbose=1, regenerate=regen
    )
    rc = i._run(args)
    assert rc == 0


def test_import_run_rollback(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
    """Test successful _run() on invalid input with good or failed rollback."""
    # rollback tests are deprecated because import validates the file in memory prior to writing out

    dup_cat = {
        'catalog': {
            'uuid': '525f94af-8007-4376-8069-aa40179e0f6e',
            'metadata': {
                'title': 'Generic catalog created by trestle.',
                'last-modified': '2020-12-11T02:04:51.053+00:00',
                'version': '0.0.0',
                'oscal-version': trestle.oscal.OSCAL_VERSION
            },
            'back-matter': {
                'resources': [
                    {
                        'uuid': 'b1101385-9e36-44a3-ba03-98b6ebe0a367'
                    }, {
                        'uuid': 'b1101385-9e36-44a3-ba03-98b6ebe0a367'
                    }
                ]
            }
        }
    }
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    dup_file_name = f'{tmp_trestle_dir.parent}/dup-{rand_str}.json'
    dup_file = pathlib.Path(dup_file_name).open('w+', encoding=const.FILE_ENCODING)
    dup_file.write(json.dumps(dup_cat, ensure_ascii=False))
    dup_file.close()
    j = importcmd.ImportCmd()
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=dup_file_name, output=f'dup-{rand_str}', verbose=1, regenerate=False
    )
    # 1. Validation rejects above import, which results in non-zero exit code for import.
    rc = j._run(args)
    assert rc > 0


def test_import_clash_on_output(tmp_trestle_dir: pathlib.Path) -> None:
    """Test an attempt to import into an existing trestle file."""
    # 1. Create a sample catalog,
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, output='my-catalog', extension='json', verbose=1, include_optional_fields=False
    )
    create.CreateCmd.create_object('catalog', Catalog, args)
    # 2. Create a valid oscal object in tmp_trestle_dir.parent,
    sample_data = generators.generate_sample_model(Catalog)
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    sample_data.oscal_write(pathlib.Path(f'{tmp_trestle_dir.parent}/{rand_str}.json'))
    # 3. then attempt to import that out to the previously created catalog, forcing the clash:
    i = importcmd.ImportCmd()
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=f'{tmp_trestle_dir.parent}/{rand_str}.json', output='my-catalog', verbose=1
    )
    rc = i._run(args)
    assert rc == 1


def test_import_non_top_level_element(tmp_trestle_dir: pathlib.Path) -> None:
    """Test for expected fail to import non-top level element, e.g., groups."""
    # Input file, catalog:
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    groups_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    groups_data = generators.generate_sample_model(Group)
    groups_data.oscal_write(pathlib.Path(groups_file))
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=groups_file, output='imported', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1


def test_import_missing_input_file(tmp_trestle_dir: pathlib.Path) -> None:
    """Test for missing input file."""
    # Test
    args = argparse.Namespace(
        file='/dummy_path/random_named_file.json', output='catalog', verbose=1, trestle_root=tmp_trestle_dir
    )
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1


def test_import_bad_working_directory(tmp_path: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
    """Test for working directory that is not a trestle initialized directory."""

    def get_trestle_project_root_mock(*args, **kwargs):
        return None

    # Input file, catalog:
    catalog_file_path = pathlib.Path.joinpath(test_utils.JSON_TEST_DATA_PATH.resolve(), 'minimal_catalog.json')
    args = argparse.Namespace(trestle_root=tmp_path, file=str(catalog_file_path), output='catalog', verbose=1)
    i = importcmd.ImportCmd()
    monkeypatch.setattr(fs, 'extract_trestle_project_root', get_trestle_project_root_mock)
    rc = i._run(args)
    assert rc == 5


def test_import_from_inside_trestle_project_is_bad(tmp_trestle_dir: pathlib.Path) -> None:
    """Test for attempting import from a trestle workspace directory."""
    sample_file = open('infile.json', 'w+', encoding=const.FILE_ENCODING)
    sample_file.write('{}')
    sample_file.close()
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file='infile.json', output='catalog', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1


def test_import_bad_input_extension(tmp_trestle_dir: pathlib.Path) -> None:
    """Test for bad input extension."""
    # Some input file with bad extension.
    temp_file = tempfile.NamedTemporaryFile(suffix='.txt')
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=temp_file.name, output='catalog', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1


def test_import_load_file_failure(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
    """Test model load failures."""

    def load_file_mock(*args, **kwargs):
        raise err.TrestleError('stuff')

    def load_file_permission_error_mock(*args, **kwargs):
        raise PermissionError

    def load_file_json_decode_error_mock(*args, **kwargs):
        raise JSONDecodeError(msg='Extra data:', doc=bad_file.name, pos=0)

    # Create a file with bad json
    sample_data = '"star": {'
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    bad_file = pathlib.Path(f'{tmp_trestle_dir.parent}/{rand_str}.json').open('w+', encoding=const.FILE_ENCODING)
    bad_file.write(sample_data)
    bad_file.close()
    monkeypatch.setattr(fs, 'load_file', load_file_mock)
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=bad_file.name, output='imported', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1
    # Force PermissionError:
    monkeypatch.setattr(fs, 'load_file', load_file_permission_error_mock)
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=bad_file.name, output='imported', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1
    # Force JSONDecodeError:
    monkeypatch.setattr(fs, 'load_file', load_file_json_decode_error_mock)
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=bad_file.name, output='imported', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1
    # This is in case the same tmp_trestle_dir.parent is used, as across succeeding scopes of one pytest
    os.chmod(bad_file.name, 0o600)
    os.remove(bad_file.name)


def test_import_root_key_failure(tmp_trestle_dir: pathlib.Path) -> None:
    """Test root key is not found."""
    sample_data = {'id': '0000', 'title': 'nothing'}
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    sample_file = pathlib.Path(f'{tmp_trestle_dir.parent}/{rand_str}.json').open('w+', encoding=const.FILE_ENCODING)
    sample_file.write(json.dumps(sample_data, ensure_ascii=False))
    sample_file.close()
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=sample_file.name, output='catalog', verbose=1)
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1


def test_import_root_key_found(tmp_trestle_dir: pathlib.Path) -> None:
    """Test root key is found."""
    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    catalog_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    catalog_data = generators.generate_sample_model(Catalog)
    catalog_data.oscal_write(pathlib.Path(catalog_file))
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=catalog_file, output='catalog', verbose=1, regenerate=False
    )
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 0


def test_import_failure_execute_plan(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
    """Test model failures throw errors and exit badly."""

    def execute_plan_mock(*args, **kwargs):
        raise err.TrestleError('stuff')

    rand_str = ''.join(random.choice(string.ascii_letters) for x in range(16))
    catalog_file = f'{tmp_trestle_dir.parent}/{rand_str}.json'
    catalog_data = generators.generate_sample_model(Catalog)
    catalog_data.oscal_write(pathlib.Path(catalog_file))
    monkeypatch.setattr(Plan, 'execute', execute_plan_mock)
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=catalog_file, output='imported', verbose=1, regenerate=False
    )
    i = importcmd.ImportCmd()
    rc = i._run(args)
    assert rc == 1


def test_import_from_url(tmp_trestle_dir: pathlib.Path) -> None:
    """Test import via url."""
    uri = (
        'https://raw.githubusercontent.com/oscal-compass/compliance-trestle/develop/'
        'tests/data/json/minimal_catalog.json'
    )
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=uri, output='my_catalog', verbose=1, regenerate=False)
    i = importcmd.ImportCmd()
    assert i._run(args) == 0
    imported_catalog = Catalog.oscal_read(tmp_trestle_dir / 'catalogs/my_catalog/catalog.json')

    test_catalog = test_utils.JSON_TEST_DATA_PATH / 'minimal_catalog.json'
    catalog_data = Catalog.oscal_read(test_catalog)

    assert ModelUtils.models_are_equivalent(catalog_data, imported_catalog)


def test_import_from_nist(tmp_trestle_dir: pathlib.Path) -> None:
    """Test import via url from nist."""
    uri = 'https://raw.githubusercontent.com/usnistgov/oscal-content/690f517daaf3a6cbb4056d3cde6eae2756765620/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_HIGH-baseline_profile-min.json'  # noqa: E501
    args = argparse.Namespace(trestle_root=tmp_trestle_dir, file=uri, output='my_catalog', verbose=1, regenerate=False)
    i = importcmd.ImportCmd()
    assert i._run(args) == 0


def test_import_load_profile(tmp_trestle_dir: pathlib.Path) -> None:
    """Test load of profile and contents."""
    external_prof_path = test_utils.JSON_TEST_DATA_PATH / 'test_profile_e.json'
    read_profile: Profile = Profile.oscal_read(external_prof_path)
    assert len(read_profile.modify.set_parameters[1].constraints) == 1

    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=str(external_prof_path), output='my_prof', verbose=1, regenerate=False
    )
    i = importcmd.ImportCmd()
    assert i._run(args) == 0

    loaded_profile, _ = ModelUtils.load_model_for_class(tmp_trestle_dir, 'my_prof', Profile)
    assert len(loaded_profile.modify.set_parameters[1].constraints) == 1


def test_import_wrong_oscal_version(tmp_trestle_dir: pathlib.Path) -> None:
    """Test import of the wrong OSCAL version."""
    catalog_path = test_utils.JSON_TEST_DATA_PATH / 'minimal_catalog_bad_oscal_version.json'
    args = argparse.Namespace(
        trestle_root=tmp_trestle_dir, file=str(catalog_path), output='my_catalog', verbose=1, regenerate=False
    )
    i = importcmd.ImportCmd()
    assert i._run(args) == 1
