import io
import json
import shutil
from pathlib import Path
import os.path
from typing import List, Optional, Callable, Any, Tuple
import pytest
from pytest_steps import test_steps
from copy import deepcopy

import api_debug
import pwndoc_api
from api_process_findings import ProcessingSettings, ProcessingStatus, upload_scan_findings_to_pwndoc
from helpers.file_utils import json_safe_load
from tests.helpers_for_test import pwndoc_connectivity_test
from tests.helper_anotate_tests import skip_and_anotate_problematic_test_files
from tests.conftest import TMP_PATH, PWNDOC_DANGER_OVERRIDE
import config


def find_nessus_test_files() -> List[str]:
    answer = [str(x) for x in Path('tests/test_files/scanner_results').rglob('*.nessus')]
    answer = skip_and_anotate_problematic_test_files(answer)
    return answer


def find_burp_test_files() -> List[str]:
    answer = [str(x) for x in Path('tests/test_files/scanner_results').rglob('*/burp/*.xml')]
    answer = skip_and_anotate_problematic_test_files(answer)
    return answer


def find_lynis_test_files() -> List[str]:
    answer = [str(x) for x in Path('tests/test_files/scanner_results').rglob('*-lynis-*.dat')]
    answer = skip_and_anotate_problematic_test_files(answer)
    return answer


def find_all_scanner_test_files() -> List[str]:
    return find_nessus_test_files() + find_burp_test_files() + find_lynis_test_files()


def find_direct_import_folders() -> List[str]:
    answer = [f.path for f in os.scandir('tests/test_files/scan2report_results') if f.is_dir()]
    return answer


@pytest.mark.dependency()
def test_connectivity():
    pwndoc_connectivity_test()


class PwnDocCommunication:

    @staticmethod
    def setup_folder_for_direct_import(test_folder_path: str) -> str:
        ps = ProcessingSettings(audit_id="TODO_FILL_IN")
        ps.save()  # this creates a folder
        shutil.copytree(test_folder_path, ps.get_folder_path(), ignore=shutil.ignore_patterns('_pwndoc_importer_processing.json'), dirs_exist_ok=True)

        original_ps_path = os.path.join(test_folder_path, '_pwndoc_importer_processing.json')
        if not Path(original_ps_path).is_file():
            return ps.get_folder_name()

        original_ps: ProcessingSettings = ProcessingSettings.from_dict(json_safe_load(original_ps_path))

        ps.stored_files = original_ps.stored_files
        ps.missing_templates = original_ps.missing_templates
        ps.output_unspecified = original_ps.output_unspecified
        ps.save()
        return ps.get_folder_name()

    @classmethod
    def default_finding_modification_function(cls, orig: dict, finding_order_id: int) -> dict:
        orig['fid'] += f'_copy_{finding_order_id}'
        orig['name'] += f'_copy_{finding_order_id}'
        return orig

    @classmethod
    def setup_fake_scan2report_import(cls,
                                      original_folder_path: str,
                                      number_of_findings: int = 1,
                                      modification_function: Optional[Callable[[Any], dict]] = None,
                                      modification_params: Optional[dict] = None
                                      ) -> str:
        if modification_function is None:
            modification_function = cls.default_finding_modification_function
        modification_params = modification_params if modification_params else {}

        folder_name = cls.setup_folder_for_direct_import(original_folder_path)
        ps: ProcessingSettings = ProcessingSettings.load_from_folder_name(folder_name)
        findings_path = os.path.join(ps.get_folder_path(), 'scan2report_output.json')

        template_finding = json_safe_load(findings_path)[0]
        findings_data: List[dict] = []

        for i in range(0, number_of_findings):
            new_finding = modification_function(deepcopy(template_finding), i, **modification_params)
            findings_data.append(new_finding)

        with open(findings_path, "w", encoding="utf8") as f:
            json.dump(findings_data, f, indent=4)
        cls.upload_proccessed_findings_to_pwndoc(folder_name, f"Test - Direct import - {folder_name}")
        return folder_name

    @staticmethod
    def process_findings(client, filepaths: List[str] = None, output_name: str = None, files: List[Tuple[io.BytesIO, str]] = None, profile: str = 'default') -> str:
        assert bool(filepaths) != bool(files), "Only filepaths OR files should be passed."
        if files is None:
            files = [open(filepath, 'rb') for filepath in filepaths]
        else:
            assert output_name

        response = client.post(
            '/import_automator/findings/upload_scanner_result',
            follow_redirects=True,
            data={
                'audit': 'json',
                'locale': 'cs',
                'profile': profile,
                'min-severity': '0',
                'scan2report_args': '',
                'file[]': files,
            },
        )
        assert response.status_code == 200, f'The response code indicates uncaught failure. Status code: {response.status_code}'

        if output_name is None:
            output_name = os.path.basename(filepaths[0])
        with open(os.path.join(TMP_PATH, f'{output_name}.html'), 'w', encoding='utf8') as f:
            f.write(response.text)

        url_path = response.request.path
        _, folder_name = url_path.rstrip("/").rsplit("/", 1)
        # status_filepath = os.path.join(config.FLASK_UPLOAD_FOLDER, folder_id, '_pwndoc_importer_processing.json')
        # status = helpers.file_utils.json_safe_load(status_filepath)
        ps = ProcessingSettings.load_from_folder_name(folder_name)
        assert ps is not None, "Report of import should exist"

        for step_name, (step_status, _, _) in ps.steps_progress.items():
            assert step_status != ProcessingStatus.FAILED.name, f"Step {step_name} failed."

        for level, msg in ps.user_msgs:
            assert level != 'error', f'There is at least one error msg displayed to user: {msg}'

        return folder_name

    @staticmethod
    def upload_proccessed_findings_to_pwndoc(folder_name: str, test_filename: str = ""):
        def _safe_name(dangerous_name: str) -> str:
            return ''.join(x for x in dangerous_name if x.isalnum())[:30]

        audit_id = pwndoc_api.create_audit(name_section=_safe_name(test_filename))
        ps = ProcessingSettings.load_from_folder_name(folder_name)
        assert ps is not None

        ps.upload_to_pwndoc = True
        ps.audit_id = audit_id
        ps.save()

        assert upload_scan_findings_to_pwndoc(folder_name)

        ps: ProcessingSettings = ProcessingSettings.load_from_folder_name(folder_name)

        error_msgs = list(filter(lambda level_and_msg: level_and_msg[0] == 'error', ps.user_msgs))
        error_msgs_strs = list(map(lambda level_and_msg: level_and_msg[0], error_msgs))
        assert len(error_msgs_strs) == 0, "Upload of findings failed: " + "\n\n".join(error_msgs_strs)

    @staticmethod
    def download_report(folder_name: str, test_filename: str):
        ps = ProcessingSettings.load_from_folder_name(folder_name)
        report_filepath = os.path.abspath(os.path.join(TMP_PATH, f'{test_filename}.docx'))
        pwndoc_api.download_report(ps.audit_id, report_filepath)
        assert Path(report_filepath).is_file()


# These tests may take 30 minutes or even more.

@pytest.mark.skipif(not PWNDOC_DANGER_OVERRIDE, reason="PWNDOC_DANGER_OVERRIDE must be allowed to enable testing of interaction with PwnDoc")
@pytest.mark.dependency(depends=["test_connectivity"])
class TestFindingParsing:

    # todo: test for double import of the same finding files
    # todo: add deletion of audits after test

    def test_formatting_is_preserved(self):
        raw_description = "**PROBLEM 1**"
        expected_description = '<p><strong>PROBLEM 1</strong></p>'

        folder_name = PwnDocCommunication.setup_fake_scan2report_import(
            'tests/test_files/scan2report_results/single-finding-result',
            1,
            self.set_description_modification_function,
            {'description': raw_description}
        )
        ps: ProcessingSettings = ProcessingSettings.load_from_folder_name(folder_name)
        audit_data = pwndoc_api.download_and_return_audit(ps.audit_id)
        new_description = audit_data['findings'][0]['description']

        assert new_description == expected_description

    @classmethod
    def direct_big_import(cls, size_in_mb: int, number_of_findings=1) -> str:
        return PwnDocCommunication.setup_fake_scan2report_import(
            'tests/test_files/scan2report_results/single-finding-result',
            number_of_findings,
            cls.big_data_modification_function,
            {'size_in_mb': size_in_mb}
        )

    @classmethod
    def big_data_modification_function(cls, orig: dict, finding_order_id: int, size_in_mb: int) -> dict:
        orig = PwnDocCommunication.default_finding_modification_function(orig, finding_order_id)
        orig['impact'] = 'X' * size_in_mb * 1024 * 1024
        return orig

# ------------- TESTS OF SIZE LIMITS (audits, findings) -------------

    # PwnDoc-Frontend nginx has limit 10 MB, Mongo has limit of 16 MB
    # Note that field "description" would get duplicated to fields: description, original_description. I.e. it would take twice the size.

    def test_direct_import_big_but_just_under_limit(self):
        self.direct_big_import(size_in_mb=9)

    @pytest.mark.xfail(reason="Pwndoc-frontend nginx doesn't allow requests larger than 10MB")
    def test_direct_import_breach_10_mb_nginx_limit(self):
        self.direct_big_import(size_in_mb=11)

    @test_steps('Upload to PwnDoc Audit', 'Download PwnDoc audit report')
    def test_direct_two_big_findings(self):
        folder_name = self.direct_big_import(size_in_mb=7, number_of_findings=2)
        yield
        PwnDocCommunication.download_report(folder_name, "two_big_findings.docx")
        yield

    @pytest.mark.xfail(reason="MongoDB has a 16 MB limit on the size of the audit document")
    @test_steps('Upload to PwnDoc Audit')
    def test_breach_mongo_db_16_mb_limit(self):
        self.direct_big_import(size_in_mb=4, number_of_findings=5)
        yield

# ------------- TESTS OF PROCESSING, IMPORTING AND REPORT GENERATION -------------

    @classmethod
    def set_description_modification_function(cls, orig: dict, finding_order_id: int, description: str) -> dict:
        orig = PwnDocCommunication.default_finding_modification_function(orig, finding_order_id)
        orig['description'] = description
        return orig

    def test_grouped_finding(self, client):
        raw_description = "" \
            "**PROBLEM 1**\n DESCRIPTION 1 \n\n" \
            "**PROBLEM 2**\n\n DESCRIPTION 2 \n\n========================================\n PROOF 2 \n\n" \
            "**PROBLEM 3**\n\n DESCRIPTION 3 \n\n========================================\n PROOF 3 \n\n" \
            "**PROBLEM 4**\n\n DESCRIPTION 4"

        PwnDocCommunication.setup_fake_scan2report_import(
            'tests/test_files/scan2report_results/single-finding-result',
            1,
            self.set_description_modification_function,
            {'description': raw_description}
        )
        # todo: is there any assert here?
        pass

    @pytest.mark.veryslow
    def test_fast_direct_import(self, client):
        self.test_direct_import(client, find_direct_import_folders()[0])

    @pytest.mark.parametrize("filepath", find_nessus_test_files()[:1] + find_burp_test_files()[:1] + find_lynis_test_files()[:1])
    def test_fast_test_processing(self, client, filepath):
        output_filename = os.path.basename(filepath)
        client.get('/import_automator/debug/delete_vulnerability_templates')
        folder_name = PwnDocCommunication.process_findings(client, [filepath], output_filename)

    @pytest.mark.veryslow
    @pytest.mark.parametrize("test_folder_path", find_direct_import_folders())
    def test_direct_import(self, client, test_folder_path):
        folder_name = PwnDocCommunication.setup_folder_for_direct_import(test_folder_path)
        PwnDocCommunication.upload_proccessed_findings_to_pwndoc(folder_name, f"Test - Direct import - {folder_name}")

    @pytest.mark.veryslow
    @test_steps('PwnDoc Templates, Scan2Report, JSON', 'Upload to PwnDoc Audit', 'Download PwnDoc audit report')
    @pytest.mark.parametrize("filepath", find_all_scanner_test_files())
    def test_individually(self, client, filepath):
        output_filename = os.path.basename(filepath)

        client.get('/import_automator/debug/delete_vulnerability_templates')

        folder_name = PwnDocCommunication.process_findings(client, [filepath], output_filename)
        yield

        PwnDocCommunication.upload_proccessed_findings_to_pwndoc(folder_name, output_filename)
        yield

        PwnDocCommunication.download_report(folder_name, output_filename)
        yield

    @pytest.mark.veryslow
    @test_steps('PwnDoc Templates, Scan2Report, JSON', 'Upload to PwnDoc Audit', 'Download PwnDoc audit report')
    def test_all_in_one(self, client):
        filepaths = find_all_scanner_test_files()
        filepaths = list(filter(lambda x: isinstance(x, str), filepaths))  # Skipping those already marked for skipping.
        output_filename = 'all_in_one'

        client.get('/import_automator/debug/delete_vulnerability_templates')

        folder_name = PwnDocCommunication.process_findings(client, filepaths, output_filename)
        yield

        PwnDocCommunication.upload_proccessed_findings_to_pwndoc(folder_name, output_filename)
        yield

        PwnDocCommunication.download_report(folder_name, output_filename)
        yield

