import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, NamedTuple, Type

from dateutil.parser import parse
from pydantic import TypeAdapter
from pydantic.v1 import ValidationError

from cg.constants import Workflow
from cg.constants.constants import FileExtensions, FileFormat, GenomeVersion, MultiQC
from cg.constants.tb import AnalysisStatus
from cg.exc import CgError, HousekeeperStoreError, MetricsQCError
from cg.io.controller import ReadFile, WriteFile
from cg.io.json import read_json
from cg.meta.workflow.analysis import AnalysisAPI
from cg.models.analysis import NextflowAnalysis
from cg.models.cg_config import CGConfig
from cg.models.deliverables.metric_deliverables import (
    MetricsBase,
    MetricsDeliverablesCondition,
    MultiqcDataJson,
)
from cg.models.nf_analysis import FileDeliverable, WorkflowDeliverables
from cg.models.qc_metrics import QCMetrics
from cg.store.models import Analysis, Case, Sample

LOG = logging.getLogger(__name__)


class MultiQCSearchPattern(NamedTuple):
    pattern: str
    sample_id: str


class NfAnalysisAPI(AnalysisAPI):
    """Parent class for handling NF-core analyses."""

    def __init__(self, config: CGConfig, workflow: Workflow):
        super().__init__(workflow=workflow, config=config)
        self.workflow: Workflow = workflow
        self.root_dir: str | None = None
        self.workflow_bin_path: str | None = None
        self.references: str | None = None
        self.profile: str | None = None
        self.conda_env: str | None = None
        self.conda_binary: str | None = None
        self.platform: str | None = None
        self.params: str | None = None
        self.workflow_config_path: str | None = None
        self.resources: str | None = None
        self.tower_binary_path: str | None = None
        self.tower_workflow: str | None = None
        self.account: str | None = None
        self.email: str | None = None
        self.revision: str | None = None

    @property
    def root(self) -> str:
        return self.root_dir

    @property
    def is_multiqc_pattern_search_exact(self) -> bool:
        """Return True if only exact pattern search is allowed to collect metrics information from MultiQC file.
        If false, pattern must be present but does not need to be exact."""
        return False

    def get_case_path(self, case_id: str) -> Path:
        """Path to case working directory."""
        return Path(self.root_dir, case_id)

    def get_deliverables_file_path(self, case_id: str) -> Path:
        """Path to deliverables file for a case."""
        return Path(self.get_case_path(case_id), f"{case_id}_deliverables").with_suffix(
            FileExtensions.YAML
        )

    def get_metrics_deliverables_path(self, case_id: str) -> Path:
        """Return a path where the <case>_metrics_deliverables.yaml file should be located."""
        return Path(self.root_dir, case_id, f"{case_id}_metrics_deliverables").with_suffix(
            FileExtensions.YAML
        )

    def verify_deliverables_file_exists(self, case_id: str) -> None:
        """Raise an error if a deliverable file is not found."""
        if not Path(self.get_deliverables_file_path(case_id=case_id)).exists():
            raise CgError(f"No deliverables file found for case {case_id}")

    @staticmethod
    def write_deliverables_file(
        deliverables_content: dict, file_path: Path, file_format=FileFormat.YAML
    ) -> None:
        """Write deliverables file."""
        WriteFile.write_file_from_content(
            content=deliverables_content, file_format=file_format, file_path=file_path
        )

    def get_deliverables_template_content(self) -> list[dict[str, str]]:
        """Return deliverables file template content."""
        LOG.debug("Getting deliverables file template content")
        return ReadFile.get_content_from_file(
            file_format=FileFormat.YAML,
            file_path=self.get_bundle_filenames_path(),
        )

    @staticmethod
    def get_bundle_filenames_path() -> Path:
        """Return bundle filenames path."""
        raise NotImplementedError

    @staticmethod
    def get_formatted_file_deliverable(
        file_template: dict[str | None, str | None],
        case_id: str,
        sample_id: str,
        sample_name: str,
        case_path: str,
    ) -> FileDeliverable:
        """Return the formatted file deliverable with the case and sample attributes."""
        deliverables = file_template.copy()
        for deliverable_field, deliverable_value in file_template.items():
            if deliverable_value is None:
                continue
            deliverables[deliverable_field] = (
                deliverables[deliverable_field]
                .replace("CASEID", case_id)
                .replace("SAMPLEID", sample_id)
                .replace("SAMPLENAME", sample_name)
                .replace("PATHTOCASE", case_path)
            )
            LOG.debug(deliverables[deliverable_field])
        return FileDeliverable(**deliverables)

    def get_deliverables_for_sample(
        self, sample: Sample, case_id: str, template: list[dict[str, str]]
    ) -> list[FileDeliverable]:
        """Return a list of FileDeliverables for each sample."""
        sample_id: str = sample.internal_id
        sample_name: str = sample.name
        case_path = str(self.get_case_path(case_id=case_id))
        files: list[FileDeliverable] = []
        for file in template:
            files.append(
                self.get_formatted_file_deliverable(
                    file_template=file,
                    case_id=case_id,
                    sample_id=sample_id,
                    sample_name=sample_name,
                    case_path=case_path,
                )
            )
        return files

    def get_deliverables_for_case(self, case_id: str) -> WorkflowDeliverables:
        """Return workflow deliverables for a given case."""
        deliverable_template: list[dict] = self.get_deliverables_template_content()
        samples: list[Sample] = self.status_db.get_samples_by_case_id(case_id=case_id)
        files: list[FileDeliverable] = []

        for sample in samples:
            bundles_per_sample = self.get_deliverables_for_sample(
                sample=sample, case_id=case_id, template=deliverable_template
            )
            files.extend(bundle for bundle in bundles_per_sample if bundle not in files)
        return WorkflowDeliverables(files=files)

    def get_multiqc_json_path(self, case_id: str) -> Path:
        """Return the path of the multiqc_data.json file."""
        return Path(
            self.root_dir,
            case_id,
            MultiQC.MULTIQC,
            MultiQC.MULTIQC_DATA,
            MultiQC.MULTIQC_DATA + FileExtensions.JSON,
        )

    def get_qc_conditions_for_workflow(self, sample_id: str) -> dict:
        """Get nf-core workflow metrics constants."""
        return {}

    def get_multiqc_search_patterns(self, case_id: str) -> list[MultiQCSearchPattern]:
        """Return a list of MultiQC search patterns for a case.
        Each pattern pairs a search string with its corresponding sample ID.
        Search patterns are typically sample IDs used to extract metrics from MultiQC data."""
        sample_ids: Iterator[str] = self.status_db.get_sample_ids_by_case_id(case_id)
        return [
            MultiQCSearchPattern(pattern=sample_id, sample_id=sample_id) for sample_id in sample_ids
        ]

    @staticmethod
    def get_deduplicated_metrics(metrics: list[MetricsBase]) -> list[MetricsBase]:
        """Return deduplicated metrics based on metric ID and name. If duplicated entries are found
        only the first one will be kept."""
        deduplicated_metric_id_name = set([])
        deduplicated_metrics: list = []
        for metric in metrics:
            if (metric.id, metric.name) not in deduplicated_metric_id_name:
                deduplicated_metric_id_name.add((metric.id, metric.name))
                deduplicated_metrics.append(metric)
        return deduplicated_metrics

    def get_multiqc_data_json(self, case_id: str) -> MultiqcDataJson:
        return MultiqcDataJson(**read_json(file_path=self.get_multiqc_json_path(case_id=case_id)))

    def get_multiqc_json_metrics(self, case_id: str) -> list[MetricsBase]:
        """Return a list of the metrics specified in a MultiQC json file."""
        multiqc_json: MultiqcDataJson = self.get_multiqc_data_json(case_id=case_id)
        metrics = []
        for pattern in self.get_multiqc_search_patterns(case_id=case_id):
            metrics_for_pattern: list[MetricsBase] = self.get_multiqc_metrics_for_sample(
                search_pattern=pattern.pattern,
                multiqc_json=multiqc_json,
                sample_id=pattern.sample_id,
                exact_match=self.is_multiqc_pattern_search_exact,
            )
            metrics.extend(metrics_for_pattern)
        metrics = self.get_deduplicated_metrics(metrics=metrics)
        return metrics

    @staticmethod
    def _is_pattern_found(pattern: str, text: str, exact_match: bool) -> bool:
        if exact_match:
            is_pattern_found: bool = pattern == text
        else:
            is_pattern_found: bool = pattern in text
        return is_pattern_found

    def get_multiqc_metrics_for_sample(
        self,
        search_pattern: str,
        multiqc_json: MultiqcDataJson,
        sample_id: str,
        exact_match: bool = False,
    ) -> list[MetricsBase]:
        """Parse a MultiqcDataJson and returns a list of metrics."""
        metrics: list[MetricsBase] = []
        list_of_metric_dicts: list[dict[str, Any]] = self._get_list_of_metric_dicts(multiqc_json)

        for section in list_of_metric_dicts:
            for subsection, metrics_dict in section.items():
                if self._is_pattern_found(
                    pattern=search_pattern, text=subsection, exact_match=exact_match
                ):
                    for metric_name, metric_value in metrics_dict.items():
                        metric: MetricsBase = self.get_multiqc_metric(
                            metric_name=metric_name, metric_value=metric_value, sample_id=sample_id
                        )
                        metrics.append(metric)
        return metrics

    def _get_list_of_metric_dicts(self, multiqc_json: MultiqcDataJson) -> list[dict[str, Any]]:
        if metric_dicts := multiqc_json.report_general_stats_data:
            return metric_dicts
        else:
            raise ValueError("No report_general_stats_data found in MultiqcDataJson")

    def get_multiqc_metric(
        self, metric_name: str, metric_value: str | int | float, sample_id: str
    ) -> MetricsBase:
        """Return a MetricsBase object for a given metric."""
        return MetricsBase(
            header=None,
            id=sample_id,
            input=MultiQC.MULTIQC_DATA + FileExtensions.JSON,
            name=metric_name,
            step=MultiQC.MULTIQC,
            value=metric_value,
            condition=self.get_qc_conditions_for_workflow(sample_id).get(metric_name, None),
        )

    @staticmethod
    def ensure_mandatory_metrics_present(metrics: list[MetricsBase]) -> None:
        return None

    def create_metrics_deliverables_content(self, case_id: str) -> dict[str, list[dict[str, Any]]]:
        """Create the content of metrics deliverables file."""
        metrics: list[MetricsBase] = self.get_multiqc_json_metrics(case_id=case_id)
        self.ensure_mandatory_metrics_present(metrics=metrics)
        return {"metrics": [metric.model_dump() for metric in metrics]}

    def write_metrics_deliverables(self, case_id: str, dry_run: bool = False) -> None:
        """Write <case>_metrics_deliverables.yaml file."""
        metrics_deliverables_path: Path = self.get_metrics_deliverables_path(case_id=case_id)
        content: dict = self.create_metrics_deliverables_content(case_id=case_id)
        if dry_run:
            LOG.info(
                f"Dry-run: metrics deliverables file would be written to {metrics_deliverables_path.as_posix()}"
            )
            return

        LOG.info(f"Writing metrics deliverables file to {metrics_deliverables_path.as_posix()}")
        WriteFile.write_file_from_content(
            content=content,
            file_format=FileFormat.YAML,
            file_path=metrics_deliverables_path,
        )

    def validate_qc_metrics(self, case_id: str, dry_run: bool = False) -> None:
        """Validate the information from a QC metrics deliverable file."""

        if dry_run:
            LOG.info("Dry-run: QC metrics validation would be performed")
            return

        LOG.info("Validating QC metrics")
        try:
            metrics_deliverables_path: Path = self.get_metrics_deliverables_path(case_id=case_id)
            qc_metrics_raw: dict = ReadFile.get_content_from_file(
                file_format=FileFormat.YAML, file_path=metrics_deliverables_path
            )
            MetricsDeliverablesCondition(**qc_metrics_raw)
        except MetricsQCError as error:
            LOG.error(f"QC metrics failed for {case_id}, with: {error}")
            self.trailblazer_api.set_analysis_status(case_id=case_id, status=AnalysisStatus.FAILED)
            samples = self.status_db.get_samples_by_case_id(case_id=case_id)
            for sample in samples:
                trailblazer_comment = str(error).replace(
                    f"{sample.internal_id} - ", f"{sample.name} ({sample.internal_id}) - "
                )
            self.trailblazer_api.add_comment(case_id=case_id, comment=trailblazer_comment)
            raise MetricsQCError from error
        except CgError as error:
            LOG.error(f"Could not create metrics deliverables file: {error}")
            self.trailblazer_api.set_analysis_status(case_id=case_id, status=AnalysisStatus.ERROR)
            raise CgError from error
        self.trailblazer_api.set_analysis_status(case_id=case_id, status=AnalysisStatus.COMPLETED)

    def metrics_deliver(self, case_id: str, dry_run: bool):
        """Create and validate a metrics deliverables file for given case id."""
        self.status_db.verify_case_exists(case_internal_id=case_id)
        self.write_metrics_deliverables(case_id=case_id, dry_run=dry_run)
        self.validate_qc_metrics(case_id=case_id, dry_run=dry_run)

    def report_deliver(self, case_id: str, dry_run: bool = False, force: bool = False) -> None:
        """Write deliverables file."""
        self.status_db.verify_case_exists(case_id)
        self.trailblazer_api.verify_latest_analysis_is_completed(case_id=case_id, force=force)
        if dry_run:
            LOG.info(f"Dry-run: Would have created delivery files for case {case_id}")
            return
        workflow_content: WorkflowDeliverables = self.get_deliverables_for_case(case_id=case_id)
        self.write_deliverables_file(
            deliverables_content=workflow_content.model_dump(),
            file_path=self.get_deliverables_file_path(case_id=case_id),
        )
        LOG.info(
            f"Writing deliverables file in {self.get_deliverables_file_path(case_id=case_id).as_posix()}"
        )

    def store_analysis_housekeeper(
        self, case_id: str, comment: str | None = None, dry_run: bool = False, force: bool = False
    ) -> None:
        """
        Store a finished Nextflow analysis in Housekeeper and StatusDB.

        Raises:
            HousekeeperStoreError: If the deliverables file is malformed or if the bundle could not be stored.
        """
        try:
            self.status_db.verify_case_exists(case_id)
            self.trailblazer_api.verify_latest_analysis_is_completed(case_id=case_id, force=force)
            self.verify_deliverables_file_exists(case_id)
            _, version = self.create_housekeeper_bundle(
                case_id=case_id, dry_run=dry_run, force=force
            )
            self.update_analysis_as_completed_statusdb(
                case_id=case_id,
                hk_version_id=version.id,
                comment=comment,
                dry_run=dry_run,
                force=force,
            )
            self.set_statusdb_action(case_id=case_id, action=None, dry_run=dry_run)
        except ValidationError as error:
            raise HousekeeperStoreError(f"Deliverables file is malformed: {error}")
        except Exception as error:
            self.housekeeper_api.rollback()
            self.status_db.session.rollback()
            raise HousekeeperStoreError(
                f"Could not store bundle in Housekeeper and StatusDB: {error}"
            )

    def store(
        self, case_id: str, comment: str | None = None, dry_run: bool = False, force: bool = False
    ):
        """Generate deliverable files for a case and store in Housekeeper if they
        pass QC metrics checks."""
        is_latest_analysis_qc: bool = self.trailblazer_api.is_latest_analysis_qc(case_id)
        is_latest_analysis_completed: bool = self.trailblazer_api.is_latest_analysis_completed(
            case_id
        )
        if not is_latest_analysis_qc and not is_latest_analysis_completed and not force:
            LOG.error(
                "Case not stored. Trailblazer status must be either QC or COMPLETED to be able to store"
            )
            raise ValueError

        if (
            is_latest_analysis_qc
            or not self.get_metrics_deliverables_path(case_id=case_id).exists()
            and not force
        ):
            LOG.info(f"Generating metrics file and performing QC checks for {case_id}")
            self.metrics_deliver(case_id=case_id, dry_run=dry_run)
        LOG.info(f"Storing analysis for {case_id}")
        self.report_deliver(case_id=case_id, dry_run=dry_run, force=force)
        self.store_analysis_housekeeper(
            case_id=case_id, comment=comment, dry_run=dry_run, force=force
        )

    def get_cases_to_store(self) -> list[Case]:
        """Return cases where analysis finished successfully,
        and is ready to be stored in Housekeeper."""
        return [
            case
            for case in self.status_db.get_running_cases_in_workflow(workflow=self.workflow)
            if self.trailblazer_api.is_latest_analysis_completed(case_id=case.internal_id)
            or self.trailblazer_api.is_latest_analysis_qc(case_id=case.internal_id)
        ]

    def get_genome_build(self, case_id: str) -> GenomeVersion:
        raise NotImplementedError

    def parse_analysis(
        self, qc_metrics_raw: list[MetricsBase], qc_metrics_model: Type[QCMetrics], **kwargs
    ) -> NextflowAnalysis:
        """Parse Nextflow output analysis files and return an analysis model."""
        sample_metrics: dict[str, dict] = {}
        for metric in qc_metrics_raw:
            try:
                sample_metrics[metric.id].update({metric.name.lower(): metric.value})
            except KeyError:
                sample_metrics[metric.id] = {metric.name.lower(): metric.value}
        pydantic_parser = TypeAdapter(dict[str, qc_metrics_model])
        return NextflowAnalysis(sample_metrics=pydantic_parser.validate_python(sample_metrics))

    def get_latest_metadata(self, case_id: str) -> NextflowAnalysis:
        """Return analysis output of a Nextflow case."""
        qc_metrics: list[MetricsBase] = self.get_multiqc_json_metrics(case_id)
        return self.parse_analysis(qc_metrics_raw=qc_metrics)

    def clean_past_run_dirs(self, before_date: str, skip_confirmation: bool = False) -> None:
        """Clean past run directories"""
        before_date: datetime = parse(before_date)
        analyses_to_clean: list[Analysis] = self.get_analyses_to_clean(before_date)
        LOG.info(f"Cleaning {len(analyses_to_clean)} analyses created before {before_date}")

        for analysis in analyses_to_clean:
            case_id = analysis.case.internal_id
            case_path = self.get_case_path(case_id)
            try:
                LOG.info(f"Cleaning output for {case_id}")
                self.clean_run_dir(
                    case_id=case_id, skip_confirmation=skip_confirmation, case_path=case_path
                )
            except FileNotFoundError:
                continue
            except Exception as error:
                LOG.error(f"Failed to clean directories for case {case_id} - {repr(error)}")
        LOG.info(f"Done cleaning {self.workflow} output")
