#    Copyright (C) 2025 James Brotosky, Brandon Wickline
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

#Local Imports
import utils.pathfunctions as pathf
import utils.utils as ct

#Standard Libary Imports:
import gc
import json
import os

#3rd Party Imports:
import pandas as pd
import requests

def aggregateHashes(executions_json) -> pd.DataFrame:
    """
    Takes the executions,  aggregates all the data with sha256 as primary, then returns aggregated dataframe
    """
    data = json.loads(executions_json)
    df = pd.DataFrame(data["response"]["exechistories"])

    if df.empty:
         return df
    print(df)
    # Aggregate by sha256, deduplicate lists, and preserve order
    agg_df = df.groupby("sha256").agg(lambda x: list(dict.fromkeys(x))).reset_index()

    # Add a column for the number of unique hostnames
    agg_df["num_devices"] = agg_df["hostname"].apply(len)

    # Sort by num_devices in descending order
    agg_df = agg_df.sort_values("num_devices", ascending=False)

    return agg_df

def augmentAggregatedHashes(url, agg_df: pd.DataFrame) -> pd.DataFrame:
    """
    Takes output of aggregatedHashes, queries API for those hashes, flattens response while keeping one row per hash,
    aggregate applications and baselines into lists, then merges results back into agg_df to create a 
    """
    if 'sha256' not in agg_df.columns or agg_df.empty:
        print("⚠️ 'sha256' column missing or DataFrame is empty. Skipping API query.")
        return agg_df.copy()  # Return as-is to avoid breaking downstream logic

    endpoint = url + '/v1/hash/query'
    payload = {
        "hashes": agg_df['sha256'].tolist()
    }

    headers = {"X-APIKey": os.getenv('APIKEY')}
    payload = json.dumps(payload)

    response = requests.post(endpoint, headers=headers, data=payload, verify=False)
    data = response.json()
    results = data.get("response", {}).get("results", [])

    rows = []
    for res in results:
        row = {"sha256": res.get("sha256"), "result": res.get("result")}

        if "data" in res:
            d = res["data"]
            for key in ["filename", "filepath", "description", "filesize", "md5",
                        "productname", "productversion", "publisher", "createtime", "modtime",
                        "sha128", "sha384", "sha512", "datetime"]:
                row[key] = d.get(key)

            row["applications"] = d.get("applications", [])
            row["baselines"] = d.get("baselines", [])

            reputation = d.get("reputation", {})
            for k, v in reputation.items():
                row[f"reputation_{k}"] = v

        rows.append(row)

    df_api = pd.DataFrame(rows)

    if 'sha256' not in df_api.columns:
        print("⚠️ API response missing 'sha256'. Skipping merge.")
        return agg_df.copy()

    df = agg_df.merge(df_api, on="sha256", how="left")

    # Only include columns that exist to avoid KeyErrors
    expected_columns = ['policy','sha256', 'filename_x', 'description', 'productname', 'productversion',
                        'publisher_y', 'publisher_x', 'netdomain', 'hostname', 'username',
                        'reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount',
                        'reputation_status', 'reputation_threatlevel', 'reputation_threatname',
                        'reputation_timestamp', 'pprocess', 'gprocess', 'commandline']

    available_columns = [col for col in expected_columns if col in df.columns]
    aug_df = df[available_columns]

    return aug_df

def categorizeHashes(df: pd.DataFrame, threat_tolerance: int, untrusted_publishers, pups: list) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    if untrusted_publishers is None: untrusted_publishers = []
    if pups is None: pups = []

    def reputationtool(row):
        val = row["reputation_scannermatch"]
        if pd.isna(val) or val == "N/A":
            return row["publisher"] == "Not Signed"
        try:
            return int(val) > threat_tolerance
        except (ValueError, TypeError):
            return row["publisher"] == "Not Signed"

    df["reputation_flag"] = df.apply(reputationtool, axis=1)

    mask_needsreview = (
        ((df["publisher"] == "Not Signed") & df["reputation_flag"]) |
        (df["reputation_status"] == "UNKNOWN")
    )

    mask_approved = (
        (
            (df["publisher"] != "Not Signed") &
            ~df["publisher"].str.contains(pathf.regulator(untrusted_publishers), case=False, na=False) &
            ~df["reputation_status"].isna() &
            ~df["description"].str.contains(pathf.regulator(pups), case=False, na=False)
        ) |
        (
            (df["publisher"] == "Not Signed") &
            ~df["reputation_flag"] &
            ~df["publisher"].str.contains(pathf.regulator(untrusted_publishers), case=False, na=False) &
            ~df["reputation_status"].isna() &
            ~df["description"].str.contains(pathf.regulator(pups), case=False, na=False)
        )
    )

    needsreview_df = df[mask_needsreview]
    approved_df = df[mask_approved]
    unapproved_df = df[~(mask_needsreview | mask_approved)]
    return needsreview_df, approved_df, unapproved_df

def combineHashAndHist(hash_path, condensed_path):
    # Load both datasets
    condensed_combo = pd.read_parquet(condensed_path)
    df = pd.read_parquet(hash_path)
    # Merge on sha256
    df = pd.merge(condensed_combo, df, on='sha256', how='inner')
   # Rename and reorder columns
    df = df.rename(columns={'publisher_x': 'publisher'})
    df = df.rename(columns={'policy_x': 'policy'})
    df = df[['policy','sha256', 'publisher', 'description', 'filename', 'hostname', 'username',
             'productname', 'productversion', 'reputation_lastseen', 'reputation_scannermatch',
             'reputation_scannercount', 'reputation_status', 'reputation_threatlevel',
             'reputation_threatname', 'reputation_timestamp', 'pprocess', 'gprocess', 'commandline']]
    df = df.sort_values(by='filename')

    # Overwrite the original hash file
    df.to_parquet(hash_path, index=False)

    # Cleanup
    del df
    del condensed_combo
    gc.collect()

def combineHashes(url, parquet_files) -> pd.DataFrame:
    combined_hashes = pd.DataFrame()
    hashes = []

    for file_path in parquet_files:
        try:
            hash_df = pd.read_parquet(file_path)
            pathf.inspect_parquet(file_path)

            if not hash_df.empty:
                hashes.append(hash_df)
            else:
                print(f"⚠️ Dataframe is empty: {file_path}")
        except Exception as e:
            print(f"❌ Error reading Parquet file '{file_path}': {e}")

    if hashes:
        combined_hashes = pd.concat(hashes, ignore_index=True)
        print(f"✅ Combined {len(combined_hashes)} hashes from {len(hashes)} files.")
    else:
        print("⚠️ No valid dataframes to combine.")

    combined_hashes = combined_hashes.drop_duplicates(subset=['sha256'])
    augmented_combo = augmentAggregatedHashes(url, combined_hashes)

    numeric_reputation_cols = [
        'reputation_scannermatch',
        'reputation_scannercount',
        'reputation_threatlevel'
    ]

    for col in numeric_reputation_cols:
        if col in augmented_combo.columns:
            augmented_combo[col] = pd.to_numeric(augmented_combo[col].replace('N/A', pd.NA), errors='coerce')

    augmented_combo = augmented_combo.rename(columns={'publisher_x': 'publisher'})
    augmented_combo = augmented_combo[['sha256', 'publisher', 'description', 'productname', 'productversion',
                                    'reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount',
                                    'reputation_status', 'reputation_threatlevel', 'reputation_threatname',
                                    'reputation_timestamp']]
    augmented_combo = augmented_combo.sort_values(by=['publisher', 'description', 'productname'])
    del combined_hashes
    gc.collect()
    print(ct.colorText("Hash reputation info added to dataframe", "green"))
    return augmented_combo

def condenseExecutions(parquet_paths):
    combined_df = pd.DataFrame()
    valid_files = []

    for file_path in parquet_paths:
        try:
            df = pd.read_parquet(file_path)
            # Optional: pathf.inspect_parquet(file_path)
            if not df.empty:
                combined_df = pd.concat([combined_df, df], ignore_index=True)
                valid_files.append(file_path)
                print(f"✅ Loaded {len(df)} rows from {file_path}")
            else:
                print(f"⚠️ DataFrame from '{file_path}' is empty.")
        except Exception as e:
            print(f"❌ Error reading Parquet file '{file_path}': {e}")

    if not combined_df.empty:
        print(f"✅ Combined {len(combined_df)} rows from {len(valid_files)} files.")
    else:
        print("⚠️ No valid dataframes to combine.")

    return combined_df

def divideSortedHashExecutions(unknown_parq, good_parq, bad_parq, condensed_parq, pups) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    # Run combineHashAndHist on each file
    combineHashAndHist(unknown_parq, condensed_parq)
    combineHashAndHist(good_parq, condensed_parq)
    combineHashAndHist(bad_parq, condensed_parq)

    # Load data
    unknown = pd.read_parquet(unknown_parq)
    good = pd.read_parquet(good_parq)
    bad = pd.read_parquet(bad_parq)

    # Build regex pattern once
    pattern = pathf.regulator(pups)

    # Move matching rows from unknown and good to bad
    bad = pd.concat([
        bad,
        unknown[unknown["filename"].str.contains(pattern, na=False)],
        good[good["filename"].str.contains(pattern, na=False)]
    ], ignore_index=True)

    # Remove matching rows from unknown and good
    unknown = unknown[~unknown["filename"].str.contains(pattern, na=False)]
    good = good[~good["filename"].str.contains(pattern, na=False)]

    return unknown, good, bad

def generatePreflights(hashes, primary_path, secondary_path):
    all_hashes = pd.read_parquet(hashes)
                    
    primarypathexclusions = ct.tryToReadCSV(primary_path)
    secondarypathexclusions = ct.tryToReadCSV(secondary_path)

    pathexclusions = pd.concat([primarypathexclusions, secondarypathexclusions], ignore_index=True)    
    
    allowbyhash = all_hashes[~all_hashes['sha256'].isin(pathexclusions['sha256'])]

    allowbyhash.sort_values(by=["filename"])
    
    return pathexclusions, allowbyhash

def generatePublist(all_hashes, bad_publisher_list):
    all_approved_hashes = ct.tryToReadParquet(all_hashes)

    #Drop all not signed, only keep unique values
    publist = all_approved_hashes[all_approved_hashes['publisher'] != "Not Signed"].drop_duplicates(subset=['publisher'])
    #Remove Bad publisher if somehow they made it this far
    pattern = pathf.regulator(bad_publisher_list)
    publist = publist[~publist["publisher"].str.contains(pattern, na=False)]

    return publist
    