"""TP02: CSV cleaning with Strategy, decorators, Factory and Template Method.

Run: python solution.py
The default input is the original course CSV; outputs stay beside this script.
"""
from abc import ABC, abstractmethod
from functools import wraps
from pathlib import Path
from time import perf_counter
import argparse
import json
import logging

import pandas as pd
from pandas.api.types import is_numeric_dtype, is_bool_dtype

LOGGER = logging.getLogger("tp02")
BASE = Path(__file__).resolve().parent
COURSE = BASE.parents[2]
DEFAULT_INPUT = COURSE / "sample_data (2).csv"


def log_action(method):
    """Log start and finish even when a method raises; preserve its metadata."""
    @wraps(method)
    def wrapper(*args, **kwargs):
        LOGGER.info("START %s", method.__qualname__)
        try:
            return method(*args, **kwargs)
        except Exception:
            LOGGER.exception("FAILED %s", method.__qualname__)
            raise
        finally:
            LOGGER.info("FINISH %s", method.__qualname__)
    return wrapper


def log_time(method):
    """Measure wall-clock elapsed seconds, including exceptional exits."""
    @wraps(method)
    def wrapper(*args, **kwargs):
        start = perf_counter()
        try:
            return method(*args, **kwargs)
        finally:
            LOGGER.info("TIME %s %.6f seconds", method.__qualname__,
                        perf_counter() - start)
    return wrapper


class CSVReader:
    def __init__(self, file_path):
        self.file_path = Path(file_path)
        self._df = None

    @log_action
    @log_time
    def read(self):
        self._df = pd.read_csv(self.file_path)
        return self._df.copy(deep=True)

    def preview(self, n=5):
        if not isinstance(n, int) or isinstance(n, bool) or n < 0:
            raise ValueError("n must be a nonnegative integer")
        if self._df is None:
            self.read()
        top = self._df.head(n).copy(deep=True)
        print(top.to_string(index=False))
        return top


class MissingValueStrategy(ABC):
    @abstractmethod
    def handle(self, df):
        """Return a cleaned copy without modifying the caller's DataFrame."""


class DropMissing(MissingValueStrategy):
    def handle(self, df):
        return df.dropna().copy(deep=True)


class FillMean(MissingValueStrategy):
    def __init__(self, exclude=("id",)):
        self.exclude = set(exclude)

    def handle(self, df):
        result = df.copy(deep=True)
        for name in result.columns:
            series = result[name]
            if name in self.exclude or not is_numeric_dtype(series) or is_bool_dtype(series):
                continue
            if series.isna().any():
                mean = series.mean()
                if pd.isna(mean):
                    LOGGER.warning("%s has no observed values; mean is undefined", name)
                    continue
                # Mean imputation may turn integer measurements into fractions.
                result[name] = series.astype(float).fillna(mean)
        return result


class FillMode(MissingValueStrategy):
    def __init__(self, exclude=("id",)):
        self.exclude = set(exclude)

    def handle(self, df):
        result = df.copy(deep=True)
        for name in result.columns:
            if name in self.exclude or not result[name].isna().any():
                continue
            modes = result[name].mode(dropna=True)
            if modes.empty:
                LOGGER.warning("%s has no observed values; mode is undefined", name)
                continue
            # pandas sorts homogeneous modes: choose the first to resolve ties.
            result[name] = result[name].fillna(modes.iloc[0])
        return result


class DataCleaner:
    def __init__(self, strategy):
        self.strategy = strategy

    @log_action
    @log_time
    def clean(self, df):
        return self.strategy.handle(df)


class DataTransform(ABC):
    @abstractmethod
    def apply(self, df):
        """Return a transformed copy."""


class NormalizeColumns(DataTransform):
    """Min-max scale numerical measurements; preserve identifier columns."""
    def __init__(self, exclude=("id",)):
        self.exclude = set(exclude)

    def apply(self, df):
        result = df.copy(deep=True)
        for name in result.columns:
            series = result[name]
            if name in self.exclude or not is_numeric_dtype(series) or is_bool_dtype(series):
                continue
            lower, upper = series.min(), series.max()
            if pd.isna(lower):
                continue
            if lower == upper:
                result[name] = series.astype(float).where(series.isna(), 0.0)
            else:
                result[name] = (series - lower) / (upper - lower)
        return result


class RemoveDuplicates(DataTransform):
    def apply(self, df):
        # Equality uses all columns, including id; retain the first occurrence.
        return df.drop_duplicates(keep="first").copy(deep=True)


class StandardizeText(DataTransform):
    def apply(self, df):
        result = df.copy(deep=True)
        for name in result.columns:
            series = result[name]
            observed = series.dropna()
            if not is_numeric_dtype(series) and not observed.empty and observed.map(
                    lambda value: isinstance(value, str)).all():
                # StringDtype preserves missing values instead of making 'nan' text.
                result[name] = series.astype("string").str.strip().str.lower()
        return result


class TransformFactory:
    TYPES = {"normalize": NormalizeColumns, "deduplicate": RemoveDuplicates,
             "standardize_text": StandardizeText}

    @classmethod
    def create(cls, name):
        try:
            transform_type = cls.TYPES[name]
        except KeyError as error:
            choices = ", ".join(cls.TYPES)
            raise ValueError(f"Unknown transform {name!r}; choose {choices}") from error
        return transform_type()


class DataPipeline(ABC):
    def run(self):
        """Template Method: every subclass follows this same sequence."""
        df = self.load()
        df = self.clean(df)
        df = self.transform(df)
        self.save(df)
        return df

    @abstractmethod
    def load(self):
        pass

    @abstractmethod
    def clean(self, df):
        pass

    @abstractmethod
    def transform(self, df):
        pass

    @abstractmethod
    def save(self, df):
        pass


class CSVDataPipeline(DataPipeline):
    def __init__(self, input_path, output_path, strategy, transforms):
        self.reader = CSVReader(input_path)
        self.cleaner = DataCleaner(strategy)
        self.transforms = list(transforms)
        self.output_path = Path(output_path).resolve()
        # Course rule: every generated output must remain within _study.
        self.output_path.relative_to(COURSE / "_study")
        if self.output_path == Path(input_path).resolve():
            raise ValueError("Output must differ from input")

    def load(self):
        return self.reader.read()

    def clean(self, df):
        return self.cleaner.clean(df)

    def transform(self, df):
        for transform in self.transforms:
            df = transform.apply(df)
        return df

    def save(self, df):
        self.output_path.parent.mkdir(parents=True, exist_ok=True)
        df.to_csv(self.output_path, index=False)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    print("EXERCISE 1: first three source rows")
    reader = CSVReader(args.input)
    original = reader.read()
    reader.preview(3)
    print("\nEXERCISE 2: missing values before cleaning")
    print(original.isna().sum().to_string())
    strategies = {"drop": DropMissing(), "mean": FillMean(), "mode": FillMode()}
    for name, strategy in strategies.items():
        cleaned = DataCleaner(strategy).clean(original)
        print(f"\n{name}: {len(cleaned)} rows, {int(cleaned.isna().sum().sum())} missing cells")
        print(cleaned.to_string(index=False))
        cleaned.to_csv(BASE / f"cleaned_{name}.csv", index=False)
    means = original[["age", "height_cm", "weight_kg", "score"]].mean().to_dict()
    print("\nObserved means:", json.dumps(means, sort_keys=True))
    print("\nEXERCISES 3-5: logged pipeline with factory transforms")
    pipeline = CSVDataPipeline(args.input, BASE / "pipeline_output.csv", FillMean(),
        [TransformFactory.create(name) for name in
         ("standardize_text", "deduplicate", "normalize")])
    result = pipeline.run()
    print(result.to_string(index=False, float_format=lambda x: f"{x:.6f}"))
    print("Saved cleaned_drop.csv, cleaned_mean.csv, cleaned_mode.csv, pipeline_output.csv")


if __name__ == "__main__":
    main()
