# ---------------------------------------------------------------------------
#  Copyright (c) DevSecCode. All rights reserved.
#  Licensed under the MIT License. See LICENSE.txt in the project root for license information.
# ---------------------------------------------------------------------------
"""Supplier Performance Risk System (SPRS) score calculator for CMMC / NIST 800-171.

Phase 13 (item 2) of the compliance evolution plan. The DoD's SPRS uses a
110-point scoring model: a contractor self-assesses against the 110 NIST SP
800-171 practices and starts at 110, subtracting a per-practice value for
every NOT IMPLEMENTED practice. Scores can go negative; -203 is the
theoretical floor.

Subtraction values per practice live in our CMMC seed under the
``sprs_subtraction_value`` field (Phase 8 shipped that data). Practices in
our seed that map to 800-171 use the official DoD subtraction values
(typically 1, 3, or 5 per practice depending on impact).

This module:

- Reads the CMMC seed to recover practice -> subtraction-value mappings.
- Walks a ``ComplianceScanEvaluation`` and computes the score for the
  CMMC framework.
- Returns a structured result with the score, the list of practices that
  drove subtractions, and a per-level breakdown (Level 1 / Level 2 /
  Level 3) so a contractor can see which level threshold they meet.

DoD scoring rules summary (per DFARS 252.204-7019):
- Start at 110.
- For each NOT IMPLEMENTED practice in 800-171 (= CMMC Level 2 +
  inherited Level 1), subtract its subtraction value.
- "Implemented" means the control is effectively in place today.
- "Partially implemented" subtracts half the value, rounded.
- The final score is what gets uploaded to SPRS.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from dsc.scanner.compliance_evaluator import (
	ComplianceScanEvaluation,
	ControlResult,
	ControlStatus,
	FrameworkResult,
)


# Path to the generated CMMC seed -- single source of truth for subtraction values.
_CMMC_SEED_PATH: Path = (
	Path(__file__).resolve().parent.parent.parent.parent
	/ "compliance_seeds" / "cmmc.json"
)

# Initial SPRS score per DFARS 252.204-7019.
_INITIAL_SCORE: int = 110


# CMMC ControlStatus -> SPRS deduction multiplier. Implemented / attested
# practices contribute zero; partial implementation halves the deduction;
# everything else (gap or attestation-required without an active
# attestation) takes the full deduction.
_DEDUCTION_MULTIPLIER: dict[ControlStatus, float] = {
	ControlStatus.COVERED_CLEAN: 0.0,
	ControlStatus.ATTESTED: 0.0,
	ControlStatus.PARTIALLY_COVERED: 0.5,
	ControlStatus.COVERED_VIOLATIONS: 1.0,
	ControlStatus.NOT_COVERED: 1.0,
	ControlStatus.ATTESTATION_REQUIRED: 1.0,
	# NOT_APPLICABLE practices contribute zero (and shouldn't appear in
	# CMMC seeds anyway -- 800-171 is the in-scope set by definition).
	ControlStatus.NOT_APPLICABLE: 0.0,
}


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class SprsDeduction:
	"""One practice that contributed to the score."""
	control_id: str
	control_name: str
	cmmc_level: int
	subtraction_value: int
	multiplier: float       # 0.0 / 0.5 / 1.0
	deducted: float         # subtraction_value * multiplier
	status: ControlStatus


@dataclass(frozen=True, slots=True)
class SprsResult:
	"""Top-level SPRS score breakdown."""
	score: float
	initial: int = _INITIAL_SCORE
	max_possible: int = _INITIAL_SCORE
	practices_assessed: int = 0
	practices_implemented: int = 0
	practices_partial: int = 0
	practices_not_implemented: int = 0
	deductions: list[SprsDeduction] = field(default_factory=list)
	by_level: dict[int, dict[str, float]] = field(default_factory=dict)

	def to_dict(self) -> dict[str, Any]:
		return {
			"score": round(self.score, 1),
			"initial": self.initial,
			"max_possible": self.max_possible,
			"practices_assessed": self.practices_assessed,
			"practices_implemented": self.practices_implemented,
			"practices_partial": self.practices_partial,
			"practices_not_implemented": self.practices_not_implemented,
			"by_level": {
				str(lvl): {k: (round(v, 1) if isinstance(v, float) else v) for k, v in stats.items()}
				for lvl, stats in self.by_level.items()
			},
			"deductions": [
				{
					"control_id": d.control_id,
					"control_name": d.control_name,
					"cmmc_level": d.cmmc_level,
					"subtraction_value": d.subtraction_value,
					"multiplier": d.multiplier,
					"deducted": round(d.deducted, 1),
					"status": d.status.name,
				}
				for d in self.deductions
			],
		}


# ---------------------------------------------------------------------------
# Seed loader
# ---------------------------------------------------------------------------


def _load_subtraction_table(seed_path: Path | None = None) -> dict[str, dict[str, Any]]:
	"""Return ``{control_id: {subtraction_value, cmmc_level}}`` from the CMMC seed.

	Raises FileNotFoundError when the seed is missing -- which means the
	build_seeds.py pipeline hasn't been run. The CLI surfaces a friendlier
	message to the user.
	"""
	path = seed_path or _CMMC_SEED_PATH
	if not path.exists():
		raise FileNotFoundError(
			f"CMMC seed not found at {path}; "
			"run scripts/build_seeds.py to regenerate."
		)
	data = json.loads(path.read_text(encoding="utf-8"))
	table: dict[str, dict[str, Any]] = {}
	for control in data.get("controls", []):
		cid = control.get("control_id")
		if not cid:
			continue
		# Phase 8 shipped these fields; default sensibly so an older seed
		# doesn't crash the calculator.
		table[cid] = {
			"subtraction_value": int(control.get("sprs_subtraction_value", 1)),
			"cmmc_level": int(control.get("cmmc_level", 2)),
		}
	return table


# ---------------------------------------------------------------------------
# Score calculation
# ---------------------------------------------------------------------------


def calculate(
	evaluation: ComplianceScanEvaluation,
	*,
	seed_path: Path | None = None,
) -> SprsResult:
	"""Compute the SPRS score for the CMMC framework in *evaluation*.

	If no CMMC framework appears in the evaluation, returns an empty
	``SprsResult`` with ``score == 110`` and zero practices assessed.
	Callers needing a 800-171 score against NIST instead can synthesize
	a CMMC framework from the NIST evaluation; we don't auto-bridge to
	avoid surprising scoring decisions.
	"""
	cmmc: FrameworkResult | None = None
	for fid, framework in evaluation.frameworks.items():
		if fid.upper() == "CMMC":
			cmmc = framework
			break
	if cmmc is None:
		return SprsResult(score=float(_INITIAL_SCORE))

	subtraction_table = _load_subtraction_table(seed_path)

	# Per-level accounting: by_level[N] = {assessed, implemented, partial,
	# not_implemented, deducted}
	by_level: dict[int, dict[str, float]] = {}
	deductions: list[SprsDeduction] = []
	implemented = partial = not_implemented = 0
	score: float = float(_INITIAL_SCORE)

	for ctrl in cmmc.controls:
		entry = subtraction_table.get(ctrl.control_id)
		if entry is None:
			# Not in the seed (custom control?) -- skip rather than guess.
			continue
		subtraction = int(entry["subtraction_value"])
		level = int(entry["cmmc_level"])
		multiplier = _DEDUCTION_MULTIPLIER.get(ctrl.status, 1.0)
		deducted = subtraction * multiplier

		stats = by_level.setdefault(level, {
			"assessed": 0, "implemented": 0, "partial": 0,
			"not_implemented": 0, "deducted": 0.0,
		})
		stats["assessed"] += 1
		if multiplier == 0.0:
			stats["implemented"] += 1
			implemented += 1
		elif multiplier == 0.5:
			stats["partial"] += 1
			partial += 1
		else:
			stats["not_implemented"] += 1
			not_implemented += 1

		stats["deducted"] += deducted

		if deducted > 0:
			deductions.append(SprsDeduction(
				control_id=ctrl.control_id,
				control_name=ctrl.control_name,
				cmmc_level=level,
				subtraction_value=subtraction,
				multiplier=multiplier,
				deducted=deducted,
				status=ctrl.status,
			))
			score -= deducted

	# Sort deductions deterministically (highest impact first; ties
	# broken by control_id).
	deductions.sort(key=lambda d: (-d.deducted, d.control_id))

	return SprsResult(
		score=score,
		practices_assessed=implemented + partial + not_implemented,
		practices_implemented=implemented,
		practices_partial=partial,
		practices_not_implemented=not_implemented,
		deductions=deductions,
		by_level=by_level,
	)


# ---------------------------------------------------------------------------
# Pretty-printer for CLI
# ---------------------------------------------------------------------------


def render_terminal(result: SprsResult) -> str:
	"""Human-readable SPRS report for `dsc sprs`."""
	lines = [
		"==============================================================",
		f"  SPRS score: {result.score:.1f}  (max: {result.max_possible})",
		"==============================================================",
		f"  Practices assessed:        {result.practices_assessed}",
		f"  Implemented (no penalty):  {result.practices_implemented}",
		f"  Partial (50% penalty):     {result.practices_partial}",
		f"  Not implemented (full):    {result.practices_not_implemented}",
	]
	if result.by_level:
		lines.append("")
		lines.append("  Per-level breakdown:")
		for lvl in sorted(result.by_level.keys()):
			stats = result.by_level[lvl]
			lines.append(
				f"    Level {lvl}: assessed={stats['assessed']:>3}  "
				f"implemented={stats['implemented']:>3}  "
				f"partial={stats['partial']:>3}  "
				f"not_implemented={stats['not_implemented']:>3}  "
				f"deducted={stats['deducted']:>6.1f}"
			)

	if result.deductions:
		lines.append("")
		lines.append("  Top deductions:")
		for d in result.deductions[:15]:
			lines.append(
				f"    -{d.deducted:>4.1f}  L{d.cmmc_level}  "
				f"{d.control_id:<14}  {d.status.name:<22}  {d.control_name}"
			)
		if len(result.deductions) > 15:
			lines.append(f"    ... and {len(result.deductions) - 15} more")
	lines.append("")
	return "\n".join(lines) + "\n"
