#!/bin/env python

"""
This program generates a JSON document suitable for a D3.js
enclosure diagram visualization.
The input data is read from two CSV files:
 1) The complete system structure, including size metrics.
 2) A hotspot analysis result used to assign weights to the modules.
"""

import argparse
import csv
import json


class MergeError(Exception):
    pass


class Merged(object):
    def __init__(self):
        self._all_modules_with_complexity = {}
        self._merged = {}

    def sorted_result(self):
        # Sort on descending order:
        return sorted(
            list(self._merged.items()), key=lambda item: item[1][0], reverse=True
        )

    def extend_with(self, name, freqs):
        if name in self._all_modules_with_complexity:
            complexity = self._all_modules_with_complexity[name]
            self._merged[name] = freqs, complexity

    def record_detected(self, name, complexity):
        self._all_modules_with_complexity[name] = complexity


def write_csv(stats):
    print("module,revisions,code")
    for name, (f, c) in stats:
        print(f"{name},{f},{c}")


def parse_complexity(merged, row):
    _, name, _, _, complexity, *_ = row
    merged.record_detected(name[2:], complexity)


def parse_freqs(merged, row):
    name, freqs, *_ = row
    merged.extend_with(name, freqs)


def merge(revs_file, comp_file):
    merged = Merged()
    parse_csv(
        merged,
        comp_file,
        parse_complexity,
        expected_format="language,filename,blank,comment,code",
    )
    parse_csv(merged, revs_file, parse_freqs, expected_format="entity,n-revs")
    write_csv(merged.sorted_result())


def validate_content_by(heading, expected):
    if not expected:
        return  # no validation
    comparison = expected.split(",")
    stripped = heading[0 : len(comparison)]  # allow extra fields
    if stripped != comparison:
        raise MergeError(
            f"Erroneous content. Expected = {expected}, got = {','.join(heading)}"
        )


def parse_csv(filename, parse_action, expected_format=None):
    def read_heading_from(r):
        p = next(r)
        while p == []:
            p = next(r)
        return p

    with open(filename, "r") as csvfile:
        r = csv.reader(csvfile, delimiter=",")
        heading = read_heading_from(r)
        validate_content_by(heading, expected_format)
        return [parse_action(row) for row in r]


class StructuralElement(object):
    def __init__(self, name, complexity):
        self.name = name
        self.complexity = complexity

    def parts(self):
        return self.name.split("/")


def parse_structural_element(csv_row):
    _, name, _, _, complexity, *_ = csv_row
    return StructuralElement(name[2:], complexity)


def make_element_weight_parser(weight_column):
    """Parameterize with the column - this allows us
    to generate data from different analysis result types.
    """

    def parse_element_weight(csv_row):
        name = csv_row[0]
        weight = float(csv_row[weight_column])  # Assert not zero?
        return name, weight

    return parse_element_weight


def module_weight_calculator_from(analysis_results):
    max_raw_weight = max(analysis_results, key=lambda e: e[1])
    max_value = max_raw_weight[1]
    normalized_weights = dict(
        [(name, (1.0 / max_value) * n) for name, n in analysis_results]
    )

    def normalized_weight_for(module_name):
        if module_name in normalized_weights:
            return normalized_weights[module_name]
        return 0.0

    return normalized_weight_for


def _matching_part_in(hierarchy, part):
    return next((x for x in hierarchy if x["name"] == part), None)


def _ensure_branch_exists(hierarchy, branch):
    existing = _matching_part_in(hierarchy, branch)
    if not existing:
        new_branch = {"name": branch, "children": []}
        hierarchy.append(new_branch)
        existing = new_branch
    return existing


def _add_leaf(hierarchy, module, weight_calculator, name):
    # TODO: augment with weight here!
    new_leaf = {
        "name": name,
        "children": [],
        "size": module.complexity,
        "weight": weight_calculator(module.name),
    }
    hierarchy.append(new_leaf)
    return hierarchy


def _insert_parts_into(hierarchy, module, weight_calculator, parts):
    """Recursively traverse the hierarchy and insert the individual parts
    of the module, one by one.
    The parts specify branches. If any branch is missing, it's
    created during the traversal.
    The final part specifies a module name (sans its path, of course).
    This is where we add size and weight to the leaf.
    """
    if len(parts) == 1:
        return _add_leaf(hierarchy, module, weight_calculator, name=parts[0])
    next_branch, *rest = parts
    existing_branch = _ensure_branch_exists(hierarchy, next_branch)
    return _insert_parts_into(
        existing_branch["children"], module, weight_calculator, parts=rest
    )


def generate_structure_from(modules, weight_calculator):
    hierarchy = []
    for module in modules:
        parts = module.parts()
        _insert_parts_into(hierarchy, module, weight_calculator, parts)

    return {"name": "root", "children": hierarchy}


def write_json(result):
    print(json.dumps(result))


# TODO: turn it around: parse the weights first and add them to individual elements
# as the raw structure list is built!


def run(args):
    raw_weights = parse_csv(
        args.weights, parse_action=make_element_weight_parser(args.weightcolumn)
    )
    weight_calculator = module_weight_calculator_from(raw_weights)

    structure_input = parse_csv(
        args.structure,
        expected_format="language,filename,blank,comment,code",
        parse_action=parse_structural_element,
    )
    weighted_system_structure = generate_structure_from(
        structure_input, weight_calculator
    )
    write_json(weighted_system_structure)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="Generates a JSON document suitable for enclosure diagrams."
    )
    parser.add_argument(
        "--structure", required=True, help="A CSV file generated by cloc"
    )
    parser.add_argument(
        "--weights",
        required=True,
        help="A CSV file with hotspot results from Code Maat",
    )
    parser.add_argument(
        "--weightcolumn",
        type=int,
        default=1,
        help="The index specifying the columnt to use in the weight table",
    )
    # TODO: add arguments to specify which CSV columns to use!

    args = parser.parse_args()
    run(args)
