#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SMF Cost & Activity Analyzer V2

Professional analyzer for normalized SMF exports.

Scope:
- Reads CSV or JSONL exports that have already been decoded from binary SMF records.
- Aggregates activity by job, user, application, department, service class and hour.
- Estimates chargeback cost using a configurable cost model.
- Detects hot jobs, abnormal CPU/elapsed ratios, wait-bound workloads, zIIP offload gaps,
  high EXCP, DB2 getpage pressure, API latency issues, ABEND and high RC situations.
- Supports baseline comparison for before/after or daily regression analysis.
- Exports HTML, JSON and multiple CSV files.

Design note:
This tool intentionally does not parse raw binary SMF records. In real sites, SMF data is
usually extracted first by IFASMFDP/SMF dump utilities, vendor tools, MXG/MICS, Splunk,
DB2 tables, z/OS Connect dashboards or custom decoders, then consumed as normalized text.
"""

from __future__ import annotations

import argparse
import csv
import datetime as dt
import html
import json
import math
import os
import re
import sys
from collections import defaultdict
from dataclasses import dataclass, asdict, field
from typing import Any, Dict, Iterable, List, Optional, Tuple


VERSION = "2.0.0"

SEVERITY_ORDER = {"INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}

FIELD_ALIASES = {
    "timestamp": ["timestamp", "datetime", "time", "smf_time", "record_time", "date_time"],
    "record_type": ["record_type", "smf_type", "type", "smf_record_type"],
    "subtype": ["subtype", "record_subtype", "smf_subtype"],
    "system_id": ["system_id", "system", "sysid", "smf_system"],
    "lpar": ["lpar", "partition", "logical_partition"],
    "job_name": ["job_name", "job", "jobname", "job_id_name"],
    "job_id": ["job_id", "jes_job_id", "jobid"],
    "step_name": ["step_name", "step", "procstep"],
    "program": ["program", "pgm", "program_name"],
    "user_id": ["user_id", "userid", "user", "owner"],
    "account": ["account", "accounting", "account_code", "acct"],
    "department": ["department", "dept", "cost_center", "costcentre", "cost_center_id"],
    "application": ["application", "app", "app_name", "business_application"],
    "service_class": ["service_class", "svc_class", "wlm_service_class", "serviceclass"],
    "workload_type": ["workload_type", "workload", "category", "domain"],
    "cpu_seconds": ["cpu_seconds", "cpu_sec", "cpu", "tcb_seconds", "cpu_time"],
    "ziip_seconds": ["ziip_seconds", "ziip_sec", "ziip", "zaap_ziip_seconds"],
    "elapsed_seconds": ["elapsed_seconds", "elapsed_sec", "elapsed", "duration_seconds"],
    "excp_count": ["excp_count", "excp", "io_count", "io_operations"],
    "db2_getpages": ["db2_getpages", "getpages", "db2_getpage_count"],
    "api_requests": ["api_requests", "requests", "zos_connect_requests", "http_requests"],
    "api_avg_ms": ["api_avg_ms", "avg_response_ms", "api_response_ms", "response_ms"],
    "transaction_count": ["transaction_count", "transactions", "tx_count", "tran_count"],
    "max_rc": ["max_rc", "rc", "condition_code", "return_code", "cc"],
    "abend_code": ["abend_code", "abend", "completion_code", "comp_code"],
    "memory_mb": ["memory_mb", "storage_mb", "private_mb", "memory"],
    "queue_time_seconds": ["queue_time_seconds", "queue_seconds", "queue_time"],
}

NUMERIC_FIELDS = {
    "record_type", "subtype", "cpu_seconds", "ziip_seconds", "elapsed_seconds",
    "excp_count", "db2_getpages", "api_requests", "api_avg_ms", "transaction_count",
    "max_rc", "memory_mb", "queue_time_seconds",
}

DEFAULT_COST_MODEL = {
    "cpu_second_rate": 0.085,
    "ziip_second_rate": 0.018,
    "elapsed_second_rate": 0.0012,
    "excp_million_rate": 2.25,
    "db2_million_getpage_rate": 0.95,
    "api_thousand_request_rate": 0.12,
    "abend_penalty": 35.0,
    "rc_error_penalty": 10.0,
    "rc_critical_penalty": 25.0,
}

DEFAULT_THRESHOLDS = {
    "job_cpu_warning": 300.0,
    "job_cpu_critical": 900.0,
    "elapsed_warning": 1800.0,
    "elapsed_critical": 7200.0,
    "excp_warning": 250000.0,
    "excp_critical": 1000000.0,
    "db2_getpages_warning": 500000.0,
    "db2_getpages_critical": 2000000.0,
    "api_latency_warning": 500.0,
    "api_latency_critical": 1500.0,
    "ziip_offload_warning_pct": 20.0,
    "ziip_offload_critical_pct": 5.0,
    "wait_bound_elapsed_to_cpu_ratio": 20.0,
    "cpu_bound_cpu_to_elapsed_ratio": 0.65,
    "queue_time_warning": 120.0,
    "memory_warning_mb": 2048.0,
    "memory_critical_mb": 8192.0,
    "compare_cpu_growth_pct": 25.0,
    "compare_cost_growth_pct": 25.0,
    "compare_api_latency_growth_pct": 30.0,
}


@dataclass
class SMFRecord:
    timestamp: str = ""
    record_type: Optional[int] = None
    subtype: Optional[int] = None
    system_id: str = "UNKNOWN"
    lpar: str = "UNKNOWN"
    job_name: str = "UNKNOWN"
    job_id: str = ""
    step_name: str = ""
    program: str = ""
    user_id: str = "UNKNOWN"
    account: str = ""
    department: str = "UNKNOWN"
    application: str = "UNKNOWN"
    service_class: str = "UNKNOWN"
    workload_type: str = "UNKNOWN"
    cpu_seconds: float = 0.0
    ziip_seconds: float = 0.0
    elapsed_seconds: float = 0.0
    excp_count: float = 0.0
    db2_getpages: float = 0.0
    api_requests: float = 0.0
    api_avg_ms: float = 0.0
    transaction_count: float = 0.0
    max_rc: float = 0.0
    abend_code: str = ""
    memory_mb: float = 0.0
    queue_time_seconds: float = 0.0
    raw: Dict[str, Any] = field(default_factory=dict)

    @property
    def hour_bucket(self) -> str:
        parsed = parse_datetime(self.timestamp)
        if parsed is None:
            return "UNKNOWN"
        return parsed.strftime("%Y-%m-%d %H:00")

    @property
    def has_abend(self) -> bool:
        return bool(str(self.abend_code or "").strip())

    @property
    def cpu_elapsed_ratio(self) -> float:
        if self.elapsed_seconds <= 0:
            return 0.0
        return self.cpu_seconds / self.elapsed_seconds

    @property
    def elapsed_cpu_ratio(self) -> float:
        if self.cpu_seconds <= 0:
            return 0.0
        return self.elapsed_seconds / self.cpu_seconds

    @property
    def ziip_offload_pct(self) -> float:
        total = self.cpu_seconds + self.ziip_seconds
        if total <= 0:
            return 0.0
        return 100.0 * self.ziip_seconds / total


@dataclass
class Finding:
    severity: str
    code: str
    title: str
    entity_type: str
    entity_id: str
    metric: str
    value: Any
    threshold: Any
    recommendation: str
    evidence: str = ""


@dataclass
class AggregateRow:
    key: str
    count: int = 0
    cpu_seconds: float = 0.0
    ziip_seconds: float = 0.0
    elapsed_seconds: float = 0.0
    excp_count: float = 0.0
    db2_getpages: float = 0.0
    api_requests: float = 0.0
    api_weighted_ms_total: float = 0.0
    transaction_count: float = 0.0
    abend_count: int = 0
    max_rc: float = 0.0
    estimated_cost: float = 0.0
    memory_mb_peak: float = 0.0
    queue_time_seconds: float = 0.0

    @property
    def api_avg_ms(self) -> float:
        if self.api_requests <= 0:
            return 0.0
        return self.api_weighted_ms_total / self.api_requests

    @property
    def cpu_elapsed_ratio(self) -> float:
        if self.elapsed_seconds <= 0:
            return 0.0
        return self.cpu_seconds / self.elapsed_seconds

    @property
    def elapsed_cpu_ratio(self) -> float:
        if self.cpu_seconds <= 0:
            return 0.0
        return self.elapsed_seconds / self.cpu_seconds

    @property
    def ziip_offload_pct(self) -> float:
        total = self.cpu_seconds + self.ziip_seconds
        if total <= 0:
            return 0.0
        return 100.0 * self.ziip_seconds / total

    def to_dict(self) -> Dict[str, Any]:
        data = asdict(self)
        data["api_avg_ms"] = round(self.api_avg_ms, 3)
        data["cpu_elapsed_ratio"] = round(self.cpu_elapsed_ratio, 5)
        data["elapsed_cpu_ratio"] = round(self.elapsed_cpu_ratio, 3)
        data["ziip_offload_pct"] = round(self.ziip_offload_pct, 3)
        return rounded_dict(data)


# ---------------------------------------------------------------------------
# Demo data
# ---------------------------------------------------------------------------

SAMPLE_ROWS = [
    {
        "timestamp": "2026-05-22T01:05:00", "record_type": 30, "subtype": 5, "system_id": "ZOS1", "lpar": "LPAR1",
        "job_name": "PAYROLL1", "job_id": "JOB03197", "step_name": "STEP030", "program": "PAYLOAD",
        "user_id": "PAYUSR", "account": "FIN-001", "department": "FINANCE", "application": "PAYROLL",
        "service_class": "BATCHHI", "workload_type": "BATCH", "cpu_seconds": 950.0, "ziip_seconds": 110.0,
        "elapsed_seconds": 4200.0, "excp_count": 1280000, "db2_getpages": 1850000, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 250000, "max_rc": 12, "abend_code": "S0C7", "memory_mb": 1024,
        "queue_time_seconds": 340,
    },
    {
        "timestamp": "2026-05-22T01:15:00", "record_type": 30, "subtype": 5, "system_id": "ZOS1", "lpar": "LPAR1",
        "job_name": "INVOICE1", "job_id": "JOB03210", "step_name": "SORT010", "program": "SORT",
        "user_id": "FINUSR", "account": "FIN-002", "department": "FINANCE", "application": "BILLING",
        "service_class": "BATCHMD", "workload_type": "BATCH", "cpu_seconds": 180.0, "ziip_seconds": 12.0,
        "elapsed_seconds": 5900.0, "excp_count": 920000, "db2_getpages": 80000, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 140000, "max_rc": 4, "abend_code": "", "memory_mb": 860,
        "queue_time_seconds": 480,
    },
    {
        "timestamp": "2026-05-22T02:10:00", "record_type": 123, "subtype": 1, "system_id": "ZOS1", "lpar": "LPAR2",
        "job_name": "ZCONNAPI", "job_id": "STC01234", "step_name": "API", "program": "ZCONN",
        "user_id": "APIUSR", "account": "API-001", "department": "DIGITAL", "application": "CUSTOMER_API",
        "service_class": "ONLINE", "workload_type": "API", "cpu_seconds": 240.0, "ziip_seconds": 410.0,
        "elapsed_seconds": 3600.0, "excp_count": 85000, "db2_getpages": 980000, "api_requests": 18000,
        "api_avg_ms": 1820.0, "transaction_count": 18000, "max_rc": 0, "abend_code": "", "memory_mb": 4096,
        "queue_time_seconds": 65,
    },
    {
        "timestamp": "2026-05-22T02:45:00", "record_type": 30, "subtype": 5, "system_id": "ZOS1", "lpar": "LPAR2",
        "job_name": "DB2LOAD", "job_id": "JOB03301", "step_name": "LOAD01", "program": "DSNUTILB",
        "user_id": "DBAUSR", "account": "DATA-001", "department": "DATA", "application": "WAREHOUSE",
        "service_class": "BATCHHI", "workload_type": "DB2", "cpu_seconds": 620.0, "ziip_seconds": 40.0,
        "elapsed_seconds": 3800.0, "excp_count": 210000, "db2_getpages": 3100000, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 90000, "max_rc": 0, "abend_code": "", "memory_mb": 12288,
        "queue_time_seconds": 80,
    },
    {
        "timestamp": "2026-05-22T03:30:00", "record_type": 110, "subtype": 1, "system_id": "ZOS1", "lpar": "LPAR1",
        "job_name": "CICSPRD", "job_id": "STC02222", "step_name": "CICS", "program": "DFHSIP",
        "user_id": "CICSUSR", "account": "OPS-001", "department": "OPERATIONS", "application": "CICS_CORE",
        "service_class": "ONLINE", "workload_type": "CICS", "cpu_seconds": 420.0, "ziip_seconds": 25.0,
        "elapsed_seconds": 3600.0, "excp_count": 120000, "db2_getpages": 620000, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 650000, "max_rc": 0, "abend_code": "", "memory_mb": 3072,
        "queue_time_seconds": 12,
    },
    {
        "timestamp": "2026-05-22T04:00:00", "record_type": 30, "subtype": 5, "system_id": "ZOS1", "lpar": "LPAR3",
        "job_name": "ARCHIVE1", "job_id": "JOB03381", "step_name": "COPY01", "program": "IEBGENER",
        "user_id": "OPSUSR", "account": "OPS-002", "department": "OPERATIONS", "application": "ARCHIVE",
        "service_class": "BATCHLO", "workload_type": "BATCH", "cpu_seconds": 34.0, "ziip_seconds": 0.0,
        "elapsed_seconds": 8200.0, "excp_count": 1480000, "db2_getpages": 0, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 0, "max_rc": 0, "abend_code": "", "memory_mb": 512,
        "queue_time_seconds": 920,
    },
    {
        "timestamp": "2026-05-22T04:20:00", "record_type": 72, "subtype": 3, "system_id": "ZOS1", "lpar": "LPAR1",
        "job_name": "WLM-SUM", "job_id": "", "step_name": "", "program": "RMF",
        "user_id": "SYSTEM", "account": "SYS", "department": "INFRA", "application": "WLM_REPORT",
        "service_class": "BATCHHI", "workload_type": "WLM", "cpu_seconds": 0.0, "ziip_seconds": 0.0,
        "elapsed_seconds": 0.0, "excp_count": 0, "db2_getpages": 0, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 0, "max_rc": 0, "abend_code": "", "memory_mb": 0,
        "queue_time_seconds": 0,
    },
    {
        "timestamp": "2026-05-22T05:05:00", "record_type": 30, "subtype": 5, "system_id": "ZOS1", "lpar": "LPAR2",
        "job_name": "AMLSCAN", "job_id": "JOB03412", "step_name": "SCAN", "program": "AMLPGM",
        "user_id": "AMLUSR", "account": "RISK-001", "department": "RISK", "application": "AML",
        "service_class": "BATCHHI", "workload_type": "BATCH", "cpu_seconds": 1250.0, "ziip_seconds": 70.0,
        "elapsed_seconds": 1600.0, "excp_count": 560000, "db2_getpages": 2400000, "api_requests": 0,
        "api_avg_ms": 0, "transaction_count": 900000, "max_rc": 0, "abend_code": "", "memory_mb": 6144,
        "queue_time_seconds": 22,
    },
]

BASELINE_ROWS = [
    dict(row, cpu_seconds=row["cpu_seconds"] * 0.72, ziip_seconds=row["ziip_seconds"] * 0.95,
         elapsed_seconds=max(row["elapsed_seconds"] * 0.80, row["elapsed_seconds"] - 300),
         excp_count=row["excp_count"] * 0.70, db2_getpages=row["db2_getpages"] * 0.65,
         api_avg_ms=row["api_avg_ms"] * 0.55 if row["api_avg_ms"] else 0,
         max_rc=0 if row["job_name"] == "PAYROLL1" else row["max_rc"],
         abend_code="" if row["job_name"] == "PAYROLL1" else row["abend_code"])
    for row in SAMPLE_ROWS
]


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def parse_datetime(value: str) -> Optional[dt.datetime]:
    if not value:
        return None
    text = str(value).strip()
    formats = [
        "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S",
        "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%m/%d/%Y %H:%M:%S",
    ]
    for fmt in formats:
        try:
            return dt.datetime.strptime(text, fmt)
        except ValueError:
            pass
    try:
        return dt.datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None)
    except Exception:
        return None


def safe_float(value: Any, default: float = 0.0) -> float:
    if value is None:
        return default
    if isinstance(value, (int, float)):
        if isinstance(value, float) and math.isnan(value):
            return default
        return float(value)
    text = str(value).strip().replace(",", "")
    if text == "" or text.upper() in {"N/A", "NA", "NULL", "NONE", "-"}:
        return default
    try:
        return float(text)
    except ValueError:
        match = re.search(r"-?\d+(?:\.\d+)?", text)
        if match:
            return float(match.group(0))
        return default


def safe_int(value: Any) -> Optional[int]:
    if value is None or value == "":
        return None
    try:
        return int(float(str(value).strip()))
    except Exception:
        return None


def normalize_text(value: Any, default: str = "UNKNOWN") -> str:
    if value is None:
        return default
    text = str(value).strip()
    if not text:
        return default
    return text.upper()


def rounded(value: Any, digits: int = 4) -> Any:
    if isinstance(value, float):
        return round(value, digits)
    return value


def rounded_dict(data: Dict[str, Any]) -> Dict[str, Any]:
    return {k: rounded(v) for k, v in data.items()}


def escape(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


def get_with_alias(row: Dict[str, Any], canonical: str) -> Any:
    lower_map = {str(k).strip().lower(): v for k, v in row.items()}
    for alias in FIELD_ALIASES.get(canonical, [canonical]):
        if alias.lower() in lower_map:
            return lower_map[alias.lower()]
    return None


def normalize_row(row: Dict[str, Any]) -> SMFRecord:
    values: Dict[str, Any] = {}
    for field_name in FIELD_ALIASES.keys():
        raw_value = get_with_alias(row, field_name)
        if field_name in NUMERIC_FIELDS:
            values[field_name] = safe_float(raw_value)
        else:
            values[field_name] = normalize_text(raw_value, default="") if raw_value not in (None, "") else ""

    record_type = safe_int(values.get("record_type"))
    subtype = safe_int(values.get("subtype"))
    max_rc = safe_float(values.get("max_rc"))

    return SMFRecord(
        timestamp=str(get_with_alias(row, "timestamp") or "").strip(),
        record_type=record_type,
        subtype=subtype,
        system_id=values.get("system_id") or "UNKNOWN",
        lpar=values.get("lpar") or "UNKNOWN",
        job_name=values.get("job_name") or "UNKNOWN",
        job_id=values.get("job_id") or "",
        step_name=values.get("step_name") or "",
        program=values.get("program") or "",
        user_id=values.get("user_id") or "UNKNOWN",
        account=values.get("account") or "",
        department=values.get("department") or "UNKNOWN",
        application=values.get("application") or "UNKNOWN",
        service_class=values.get("service_class") or "UNKNOWN",
        workload_type=values.get("workload_type") or "UNKNOWN",
        cpu_seconds=safe_float(values.get("cpu_seconds")),
        ziip_seconds=safe_float(values.get("ziip_seconds")),
        elapsed_seconds=safe_float(values.get("elapsed_seconds")),
        excp_count=safe_float(values.get("excp_count")),
        db2_getpages=safe_float(values.get("db2_getpages")),
        api_requests=safe_float(values.get("api_requests")),
        api_avg_ms=safe_float(values.get("api_avg_ms")),
        transaction_count=safe_float(values.get("transaction_count")),
        max_rc=max_rc,
        abend_code=values.get("abend_code") or "",
        memory_mb=safe_float(values.get("memory_mb")),
        queue_time_seconds=safe_float(values.get("queue_time_seconds")),
        raw=row,
    )


def read_records(path: str) -> List[SMFRecord]:
    if not os.path.exists(path):
        raise FileNotFoundError(path)
    ext = os.path.splitext(path)[1].lower()
    records: List[SMFRecord] = []
    if ext in {".jsonl", ".ndjson"}:
        with open(path, "r", encoding="utf-8", errors="ignore") as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                records.append(normalize_row(json.loads(line)))
    elif ext == ".json":
        with open(path, "r", encoding="utf-8", errors="ignore") as fh:
            payload = json.load(fh)
        rows = payload if isinstance(payload, list) else payload.get("records", [])
        for row in rows:
            records.append(normalize_row(row))
    else:
        with open(path, "r", encoding="utf-8-sig", errors="ignore", newline="") as fh:
            reader = csv.DictReader(fh)
            for row in reader:
                records.append(normalize_row(row))
    return records


def write_sample_csv(path: str, rows: List[Dict[str, Any]]) -> None:
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    fields = list(rows[0].keys())
    with open(path, "w", encoding="utf-8", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields)
        writer.writeheader()
        writer.writerows(rows)


# ---------------------------------------------------------------------------
# Analyzer
# ---------------------------------------------------------------------------

class Redactor:
    def __init__(self, enabled: bool):
        self.enabled = enabled
        self.maps: Dict[str, Dict[str, str]] = defaultdict(dict)
        self.counters: Dict[str, int] = defaultdict(int)

    def value(self, category: str, raw: str) -> str:
        if not self.enabled:
            return raw
        if not raw or raw == "UNKNOWN":
            return raw
        mapping = self.maps[category]
        if raw not in mapping:
            self.counters[category] += 1
            mapping[raw] = f"{category.upper()}_{self.counters[category]:04d}"
        return mapping[raw]

    def record(self, rec: SMFRecord) -> SMFRecord:
        if not self.enabled:
            return rec
        data = asdict(rec)
        data["job_name"] = self.value("job", rec.job_name)
        data["job_id"] = self.value("jobid", rec.job_id) if rec.job_id else ""
        data["user_id"] = self.value("user", rec.user_id)
        data["account"] = self.value("account", rec.account) if rec.account else ""
        data["department"] = self.value("department", rec.department)
        data["application"] = self.value("application", rec.application)
        data["program"] = self.value("program", rec.program) if rec.program else ""
        data["raw"] = {}
        return SMFRecord(**data)


class SMFCostActivityAnalyzer:
    def __init__(self, records: List[SMFRecord], cost_model: Dict[str, float], thresholds: Dict[str, float], profile: str = "production"):
        self.records = records
        self.cost_model = cost_model
        self.thresholds = thresholds
        self.profile = profile
        self.findings: List[Finding] = []

    def estimate_record_cost(self, rec: SMFRecord) -> float:
        cost = 0.0
        cost += rec.cpu_seconds * self.cost_model["cpu_second_rate"]
        cost += rec.ziip_seconds * self.cost_model["ziip_second_rate"]
        cost += rec.elapsed_seconds * self.cost_model["elapsed_second_rate"]
        cost += (rec.excp_count / 1_000_000.0) * self.cost_model["excp_million_rate"]
        cost += (rec.db2_getpages / 1_000_000.0) * self.cost_model["db2_million_getpage_rate"]
        cost += (rec.api_requests / 1_000.0) * self.cost_model["api_thousand_request_rate"]
        if rec.has_abend:
            cost += self.cost_model["abend_penalty"]
        if rec.max_rc >= 12:
            cost += self.cost_model["rc_critical_penalty"]
        elif rec.max_rc >= 8:
            cost += self.cost_model["rc_error_penalty"]
        return cost

    def add_to_aggregate(self, agg: AggregateRow, rec: SMFRecord) -> None:
        agg.count += 1
        agg.cpu_seconds += rec.cpu_seconds
        agg.ziip_seconds += rec.ziip_seconds
        agg.elapsed_seconds += rec.elapsed_seconds
        agg.excp_count += rec.excp_count
        agg.db2_getpages += rec.db2_getpages
        agg.api_requests += rec.api_requests
        agg.api_weighted_ms_total += rec.api_requests * rec.api_avg_ms
        agg.transaction_count += rec.transaction_count
        agg.abend_count += 1 if rec.has_abend else 0
        agg.max_rc = max(agg.max_rc, rec.max_rc)
        agg.estimated_cost += self.estimate_record_cost(rec)
        agg.memory_mb_peak = max(agg.memory_mb_peak, rec.memory_mb)
        agg.queue_time_seconds += rec.queue_time_seconds

    def aggregate_by(self, field_name: str) -> List[AggregateRow]:
        rows: Dict[str, AggregateRow] = {}
        for rec in self.records:
            key = getattr(rec, field_name) or "UNKNOWN"
            if key not in rows:
                rows[key] = AggregateRow(key=key)
            self.add_to_aggregate(rows[key], rec)
        return sorted(rows.values(), key=lambda r: r.estimated_cost, reverse=True)

    def aggregate_hourly(self) -> List[AggregateRow]:
        rows: Dict[str, AggregateRow] = {}
        for rec in self.records:
            key = rec.hour_bucket
            if key not in rows:
                rows[key] = AggregateRow(key=key)
            self.add_to_aggregate(rows[key], rec)
        return sorted(rows.values(), key=lambda r: r.key)

    def analyze(self) -> Dict[str, Any]:
        self.findings = []
        jobs = self.aggregate_by("job_name")
        users = self.aggregate_by("user_id")
        applications = self.aggregate_by("application")
        departments = self.aggregate_by("department")
        service_classes = self.aggregate_by("service_class")
        hourly = self.aggregate_hourly()

        for rec in self.records:
            self.evaluate_record(rec)
        for row in jobs:
            self.evaluate_aggregate("job", row)
        for row in applications:
            self.evaluate_aggregate("application", row)
        for row in service_classes:
            self.evaluate_aggregate("service_class", row)
        for row in hourly:
            self.evaluate_aggregate("hour", row)

        summary = self.make_summary(jobs, users, applications, departments, service_classes, hourly)
        result = {
            "version": VERSION,
            "profile": self.profile,
            "summary": summary,
            "top_jobs": [row.to_dict() for row in jobs[:50]],
            "top_users": [row.to_dict() for row in users[:50]],
            "top_applications": [row.to_dict() for row in applications[:50]],
            "top_departments": [row.to_dict() for row in departments[:50]],
            "top_service_classes": [row.to_dict() for row in service_classes[:50]],
            "hourly": [row.to_dict() for row in hourly],
            "findings": [asdict(f) for f in sorted(self.findings, key=lambda f: SEVERITY_ORDER.get(f.severity, 0), reverse=True)],
            "cost_model": self.cost_model,
            "thresholds": self.thresholds,
        }
        return result

    def make_summary(self, jobs: List[AggregateRow], users: List[AggregateRow], apps: List[AggregateRow], depts: List[AggregateRow], svc: List[AggregateRow], hourly: List[AggregateRow]) -> Dict[str, Any]:
        total_cpu = sum(r.cpu_seconds for r in self.records)
        total_ziip = sum(r.ziip_seconds for r in self.records)
        total_elapsed = sum(r.elapsed_seconds for r in self.records)
        total_excp = sum(r.excp_count for r in self.records)
        total_db2 = sum(r.db2_getpages for r in self.records)
        total_api = sum(r.api_requests for r in self.records)
        total_cost = sum(self.estimate_record_cost(r) for r in self.records)
        severity_counts = defaultdict(int)
        for finding in self.findings:
            severity_counts[finding.severity] += 1
        return rounded_dict({
            "record_count": len(self.records),
            "record_types": ", ".join(str(x) for x in sorted({r.record_type for r in self.records if r.record_type is not None})),
            "jobs": len(jobs),
            "users": len(users),
            "applications": len(apps),
            "departments": len(depts),
            "service_classes": len(svc),
            "hour_buckets": len(hourly),
            "cpu_seconds": total_cpu,
            "ziip_seconds": total_ziip,
            "ziip_offload_pct": 100.0 * total_ziip / (total_cpu + total_ziip) if total_cpu + total_ziip else 0.0,
            "elapsed_seconds": total_elapsed,
            "excp_count": total_excp,
            "db2_getpages": total_db2,
            "api_requests": total_api,
            "estimated_cost": total_cost,
            "max_rc": max([r.max_rc for r in self.records] or [0]),
            "abend_count": sum(1 for r in self.records if r.has_abend),
            "issue_count": len(self.findings),
            "critical_count": severity_counts["CRITICAL"],
            "error_count": severity_counts["ERROR"],
            "warning_count": severity_counts["WARNING"],
            "info_count": severity_counts["INFO"],
        })

    def finding(self, severity: str, code: str, title: str, entity_type: str, entity_id: str, metric: str, value: Any, threshold: Any, recommendation: str, evidence: str = "") -> None:
        self.findings.append(Finding(severity, code, title, entity_type, entity_id, metric, rounded(value), threshold, recommendation, evidence))

    def evaluate_record(self, rec: SMFRecord) -> None:
        entity = rec.job_name or rec.application or "UNKNOWN"
        evidence = f"job={rec.job_name} app={rec.application} svc={rec.service_class} record_type={rec.record_type} time={rec.timestamp}"
        if rec.has_abend:
            self.finding("CRITICAL", "ABEND_DETECTED", f"ABEND {rec.abend_code} detected", "job", entity, "abend_code", rec.abend_code, "none", "Analyze the joblog, failing step and preceding system messages. Correlate with application and dataset context.", evidence)
        if rec.max_rc >= 12:
            self.finding("CRITICAL", "MAX_RC_CRITICAL", "Critical return code detected", "job", entity, "max_rc", rec.max_rc, ">=12", "Review the failing step and prevent downstream processing until root cause is understood.", evidence)
        elif rec.max_rc >= 8:
            self.finding("ERROR", "MAX_RC_ERROR", "Error return code detected", "job", entity, "max_rc", rec.max_rc, ">=8", "Review step output and application messages. Confirm whether the job can be considered successful.", evidence)
        elif rec.max_rc >= 4:
            self.finding("WARNING", "MAX_RC_WARNING", "Warning return code detected", "job", entity, "max_rc", rec.max_rc, ">=4", "Validate whether RC=4 is acceptable for this job and step.", evidence)

        if rec.cpu_seconds >= self.thresholds["job_cpu_critical"]:
            self.finding("CRITICAL", "HOT_CPU_RECORD", "Very high CPU consumption", "job", entity, "cpu_seconds", rec.cpu_seconds, self.thresholds["job_cpu_critical"], "Review CPU hotspots, loops, SQL access paths, compiler options and batch workload growth.", evidence)
        elif rec.cpu_seconds >= self.thresholds["job_cpu_warning"]:
            self.finding("WARNING", "HOT_CPU_RECORD", "High CPU consumption", "job", entity, "cpu_seconds", rec.cpu_seconds, self.thresholds["job_cpu_warning"], "Compare with historical baseline and validate whether this job is expected to consume CPU.", evidence)

        if rec.elapsed_seconds >= self.thresholds["elapsed_critical"]:
            self.finding("CRITICAL", "LONG_ELAPSED_RECORD", "Very long elapsed time", "job", entity, "elapsed_seconds", rec.elapsed_seconds, self.thresholds["elapsed_critical"], "Check waiting time, scheduler window, dataset contention, DB2 locks, tape mounts or external dependencies.", evidence)
        elif rec.elapsed_seconds >= self.thresholds["elapsed_warning"]:
            self.finding("WARNING", "LONG_ELAPSED_RECORD", "Long elapsed time", "job", entity, "elapsed_seconds", rec.elapsed_seconds, self.thresholds["elapsed_warning"], "Compare elapsed time with CPU time to separate CPU-bound and wait-bound behavior.", evidence)

        if rec.excp_count >= self.thresholds["excp_critical"]:
            self.finding("CRITICAL", "HIGH_EXCP_RECORD", "Very high EXCP count", "job", entity, "excp_count", rec.excp_count, self.thresholds["excp_critical"], "Investigate sequential I/O, dataset placement, buffering, sort work files and VSAM access patterns.", evidence)
        elif rec.excp_count >= self.thresholds["excp_warning"]:
            self.finding("WARNING", "HIGH_EXCP_RECORD", "High EXCP count", "job", entity, "excp_count", rec.excp_count, self.thresholds["excp_warning"], "Check whether I/O growth is expected and compare with previous runs.", evidence)

        if rec.db2_getpages >= self.thresholds["db2_getpages_critical"]:
            self.finding("CRITICAL", "DB2_GETPAGE_PRESSURE", "Very high DB2 getpage activity", "job", entity, "db2_getpages", rec.db2_getpages, self.thresholds["db2_getpages_critical"], "Review SQL access paths, index usage, package changes, RUNSTATS and table scan candidates.", evidence)
        elif rec.db2_getpages >= self.thresholds["db2_getpages_warning"]:
            self.finding("WARNING", "DB2_GETPAGE_PRESSURE", "High DB2 getpage activity", "job", entity, "db2_getpages", rec.db2_getpages, self.thresholds["db2_getpages_warning"], "Compare DB2 activity with normal workload and recent application changes.", evidence)

        if rec.api_requests > 0:
            if rec.api_avg_ms >= self.thresholds["api_latency_critical"]:
                self.finding("CRITICAL", "API_LATENCY_CRITICAL", "Critical API response time", "application", rec.application, "api_avg_ms", rec.api_avg_ms, self.thresholds["api_latency_critical"], "Analyze z/OS Connect, CICS/IMS/DB2 backend response time, thread pools and network latency.", evidence)
            elif rec.api_avg_ms >= self.thresholds["api_latency_warning"]:
                self.finding("WARNING", "API_LATENCY_WARNING", "High API response time", "application", rec.application, "api_avg_ms", rec.api_avg_ms, self.thresholds["api_latency_warning"], "Compare API latency with SLO and backend workload.", evidence)

        if rec.cpu_seconds + rec.ziip_seconds > 0 and rec.workload_type in {"API", "DB2", "CICS", "BATCH"}:
            offload = rec.ziip_offload_pct
            if rec.cpu_seconds >= self.thresholds["job_cpu_warning"] and offload < self.thresholds["ziip_offload_critical_pct"]:
                self.finding("ERROR", "LOW_ZIIP_OFFLOAD", "Very low zIIP offload for CPU-heavy workload", "job", entity, "ziip_offload_pct", offload, self.thresholds["ziip_offload_critical_pct"], "Review eligible workload, DB2/zIIP configuration, Java/zCX/z/OS Connect behavior and dispatching rules.", evidence)
            elif rec.cpu_seconds >= self.thresholds["job_cpu_warning"] and offload < self.thresholds["ziip_offload_warning_pct"]:
                self.finding("WARNING", "LOW_ZIIP_OFFLOAD", "Low zIIP offload for CPU-heavy workload", "job", entity, "ziip_offload_pct", offload, self.thresholds["ziip_offload_warning_pct"], "Check whether more workload should be eligible for specialty engine offload.", evidence)

        if rec.elapsed_cpu_ratio >= self.thresholds["wait_bound_elapsed_to_cpu_ratio"] and rec.elapsed_seconds >= self.thresholds["elapsed_warning"]:
            self.finding("WARNING", "WAIT_BOUND_CANDIDATE", "Elapsed time dominates CPU time", "job", entity, "elapsed_cpu_ratio", rec.elapsed_cpu_ratio, self.thresholds["wait_bound_elapsed_to_cpu_ratio"], "Investigate I/O waits, locks, queues, scheduler dependencies, tape mounts or external services.", evidence)

        if rec.cpu_elapsed_ratio >= self.thresholds["cpu_bound_cpu_to_elapsed_ratio"] and rec.cpu_seconds >= self.thresholds["job_cpu_warning"]:
            self.finding("WARNING", "CPU_BOUND_CANDIDATE", "CPU-bound workload candidate", "job", entity, "cpu_elapsed_ratio", rec.cpu_elapsed_ratio, self.thresholds["cpu_bound_cpu_to_elapsed_ratio"], "Review algorithmic cost, SQL CPU, sort CPU and recent volume growth.", evidence)

        if rec.queue_time_seconds >= self.thresholds["queue_time_warning"]:
            self.finding("WARNING", "QUEUE_TIME_HIGH", "High queue time", "job", entity, "queue_time_seconds", rec.queue_time_seconds, self.thresholds["queue_time_warning"], "Review initiator availability, WLM policy, batch class capacity and scheduling pressure.", evidence)

        if rec.memory_mb >= self.thresholds["memory_critical_mb"]:
            self.finding("CRITICAL", "MEMORY_PEAK_HIGH", "Very high memory usage", "job", entity, "memory_mb", rec.memory_mb, self.thresholds["memory_critical_mb"], "Review REGION, storage growth, memory leaks, sort memory and DB2 utility settings.", evidence)
        elif rec.memory_mb >= self.thresholds["memory_warning_mb"]:
            self.finding("WARNING", "MEMORY_PEAK_HIGH", "High memory usage", "job", entity, "memory_mb", rec.memory_mb, self.thresholds["memory_warning_mb"], "Monitor storage usage and compare with normal runs.", evidence)

    def evaluate_aggregate(self, entity_type: str, row: AggregateRow) -> None:
        evidence = f"{entity_type}={row.key} records={row.count} cost={row.estimated_cost:.2f} cpu={row.cpu_seconds:.2f} elapsed={row.elapsed_seconds:.2f}"
        if row.abend_count > 0:
            self.finding("CRITICAL", "AGGREGATE_ABEND", f"{entity_type} has ABENDs", entity_type, row.key, "abend_count", row.abend_count, 0, "Prioritize this entity for incident review and recurring failure analysis.", evidence)
        if row.max_rc >= 12:
            self.finding("CRITICAL", "AGGREGATE_RC_CRITICAL", f"{entity_type} has critical RC", entity_type, row.key, "max_rc", row.max_rc, ">=12", "Review jobs and steps under this entity before downstream processing.", evidence)
        if row.estimated_cost >= 100.0 and entity_type in {"application", "department", "service_class"}:
            self.finding("INFO", "HIGH_COST_ENTITY", f"{entity_type} is a high cost contributor", entity_type, row.key, "estimated_cost", row.estimated_cost, ">=100", "Candidate for chargeback review, capacity planning and optimization backlog.", evidence)
        if row.api_avg_ms >= self.thresholds["api_latency_warning"] and row.api_requests > 0:
            sev = "CRITICAL" if row.api_avg_ms >= self.thresholds["api_latency_critical"] else "WARNING"
            self.finding(sev, "AGGREGATE_API_LATENCY", f"{entity_type} has elevated API latency", entity_type, row.key, "api_avg_ms", row.api_avg_ms, self.thresholds["api_latency_warning"], "Correlate API workload with backend service class and DB2/CICS activity.", evidence)


def compare_analyses(current: Dict[str, Any], baseline: Dict[str, Any], thresholds: Dict[str, float]) -> Tuple[List[Dict[str, Any]], List[Finding]]:
    rows: List[Dict[str, Any]] = []
    findings: List[Finding] = []

    def index(rows: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
        return {str(row["key"]): row for row in rows}

    for section, entity_type in [("top_applications", "application"), ("top_jobs", "job"), ("top_users", "user"), ("top_service_classes", "service_class")]:
        cur_idx = index(current.get(section, []))
        base_idx = index(baseline.get(section, []))
        all_keys = sorted(set(cur_idx) | set(base_idx))
        for key in all_keys:
            cur = cur_idx.get(key, {})
            base = base_idx.get(key, {})
            cur_cpu = safe_float(cur.get("cpu_seconds"))
            base_cpu = safe_float(base.get("cpu_seconds"))
            cur_cost = safe_float(cur.get("estimated_cost"))
            base_cost = safe_float(base.get("estimated_cost"))
            cur_api = safe_float(cur.get("api_avg_ms"))
            base_api = safe_float(base.get("api_avg_ms"))
            cpu_growth = pct_delta(cur_cpu, base_cpu)
            cost_growth = pct_delta(cur_cost, base_cost)
            api_growth = pct_delta(cur_api, base_api)
            row = rounded_dict({
                "entity_type": entity_type,
                "key": key,
                "baseline_cpu_seconds": base_cpu,
                "current_cpu_seconds": cur_cpu,
                "cpu_growth_pct": cpu_growth,
                "baseline_cost": base_cost,
                "current_cost": cur_cost,
                "cost_growth_pct": cost_growth,
                "baseline_api_avg_ms": base_api,
                "current_api_avg_ms": cur_api,
                "api_latency_growth_pct": api_growth,
            })
            rows.append(row)
            if base_cpu > 0 and cpu_growth >= thresholds["compare_cpu_growth_pct"]:
                findings.append(Finding("WARNING", "BASELINE_CPU_REGRESSION", "CPU growth versus baseline", entity_type, key, "cpu_growth_pct", round(cpu_growth, 2), thresholds["compare_cpu_growth_pct"], "Compare code, volume, SQL access path and scheduling changes between baseline and current run.", json.dumps(row)))
            if base_cost > 0 and cost_growth >= thresholds["compare_cost_growth_pct"]:
                findings.append(Finding("WARNING", "BASELINE_COST_REGRESSION", "Estimated cost growth versus baseline", entity_type, key, "cost_growth_pct", round(cost_growth, 2), thresholds["compare_cost_growth_pct"], "Candidate for chargeback review and performance regression analysis.", json.dumps(row)))
            if base_api > 0 and api_growth >= thresholds["compare_api_latency_growth_pct"]:
                findings.append(Finding("ERROR", "BASELINE_API_LATENCY_REGRESSION", "API latency growth versus baseline", entity_type, key, "api_latency_growth_pct", round(api_growth, 2), thresholds["compare_api_latency_growth_pct"], "Investigate backend response time, thread pools, DB2/CICS/IMS dependencies and network path.", json.dumps(row)))
    rows.sort(key=lambda r: (safe_float(r.get("cost_growth_pct")), safe_float(r.get("cpu_growth_pct"))), reverse=True)
    return rows, findings


def pct_delta(current: float, baseline: float) -> float:
    if baseline == 0:
        return 0.0 if current == 0 else 999.0
    return 100.0 * (current - baseline) / baseline


# ---------------------------------------------------------------------------
# Exporters
# ---------------------------------------------------------------------------

def write_json(path: str, payload: Dict[str, Any]) -> None:
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(payload, fh, indent=2)


def write_csv(path: str, rows: List[Dict[str, Any]]) -> None:
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    if not rows:
        with open(path, "w", encoding="utf-8", newline="") as fh:
            fh.write("")
        return
    fields = list(rows[0].keys())
    with open(path, "w", encoding="utf-8", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(rows)


def write_html_report(path: str, analysis: Dict[str, Any]) -> None:
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    summary = analysis["summary"]
    findings = analysis.get("findings", [])
    compare = analysis.get("baseline_compare", [])

    def table(rows: List[Dict[str, Any]], columns: List[str], limit: int = 20) -> str:
        if not rows:
            return "<p class='muted'>No data.</p>"
        head = "".join(f"<th>{escape(c)}</th>" for c in columns)
        body = []
        for row in rows[:limit]:
            body.append("<tr>" + "".join(f"<td>{escape(row.get(c, ''))}</td>" for c in columns) + "</tr>")
        return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"

    finding_cols = ["severity", "code", "title", "entity_type", "entity_id", "metric", "value", "recommendation"]
    agg_cols = ["key", "count", "estimated_cost", "cpu_seconds", "ziip_seconds", "ziip_offload_pct", "elapsed_seconds", "excp_count", "db2_getpages", "api_requests", "api_avg_ms", "abend_count", "max_rc"]
    compare_cols = ["entity_type", "key", "baseline_cost", "current_cost", "cost_growth_pct", "baseline_cpu_seconds", "current_cpu_seconds", "cpu_growth_pct", "api_latency_growth_pct"]

    html_doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SMF Cost & Activity Analyzer V2 Report</title>
<style>
body {{ font-family: Segoe UI, Arial, sans-serif; margin: 0; background: #f4f7fb; color: #17212b; }}
.header {{ background: linear-gradient(135deg, #102b3d, #0b556d); color: white; padding: 28px 34px; }}
.header h1 {{ margin: 0 0 8px; font-size: 28px; }}
.header p {{ margin: 0; color: #caedf6; }}
.wrapper {{ max-width: 1180px; margin: 24px auto; padding: 0 18px; }}
.kpis {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }}
.card {{ background: white; border: 1px solid #dce3ea; border-radius: 10px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); margin-bottom: 18px; }}
.kpi-value {{ font-weight: 800; font-size: 22px; color: #0077b6; }}
.kpi-label {{ color: #5f6b7a; font-size: 13px; }}
h2 {{ border-left: 5px solid #00b4d8; padding-left: 12px; margin-top: 30px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px; }}
th {{ background: #2c3e50; color: white; text-align: left; padding: 8px; border: 1px solid #263544; }}
td {{ padding: 8px; border: 1px solid #dce3ea; vertical-align: top; }}
tr:nth-child(even) td {{ background: #f8fafc; }}
.badge {{ display: inline-block; border-radius: 999px; padding: 3px 9px; font-weight: 700; }}
.badge-critical {{ background: #ffe7e7; color: #9b111e; }}
.badge-error {{ background: #fff0e0; color: #9a4b00; }}
.badge-warning {{ background: #fff8d7; color: #7a5a00; }}
.badge-info {{ background: #e6f7fc; color: #006c87; }}
.muted {{ color: #667085; }}
@media (max-width: 900px) {{ .kpis {{ grid-template-columns: 1fr 1fr; }} }}
</style>
</head>
<body>
<div class="header">
  <h1>SMF Cost & Activity Analyzer V2</h1>
  <p>Profile: {escape(analysis.get('profile'))} · Generated by version {escape(analysis.get('version'))}</p>
</div>
<div class="wrapper">
  <div class="kpis">
    <div class="card"><div class="kpi-value">{escape(summary.get('record_count'))}</div><div class="kpi-label">Records</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('estimated_cost'))}</div><div class="kpi-label">Estimated cost</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('cpu_seconds'))}</div><div class="kpi-label">CPU seconds</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('issue_count'))}</div><div class="kpi-label">Issues</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('applications'))}</div><div class="kpi-label">Applications</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('departments'))}</div><div class="kpi-label">Departments</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('ziip_offload_pct'))}%</div><div class="kpi-label">zIIP offload</div></div>
    <div class="card"><div class="kpi-value">{escape(summary.get('abend_count'))}</div><div class="kpi-label">ABENDs</div></div>
  </div>

  <div class="card">
    <h2>Findings</h2>
    {table(findings, finding_cols, 50)}
  </div>

  <div class="card">
    <h2>Top applications by estimated cost</h2>
    {table(analysis.get('top_applications', []), agg_cols, 20)}
  </div>

  <div class="card">
    <h2>Top jobs by estimated cost</h2>
    {table(analysis.get('top_jobs', []), agg_cols, 20)}
  </div>

  <div class="card">
    <h2>Departments / chargeback view</h2>
    {table(analysis.get('top_departments', []), agg_cols, 20)}
  </div>

  <div class="card">
    <h2>Service classes</h2>
    {table(analysis.get('top_service_classes', []), agg_cols, 20)}
  </div>

  <div class="card">
    <h2>Hourly activity</h2>
    {table(analysis.get('hourly', []), agg_cols, 30)}
  </div>

  <div class="card">
    <h2>Baseline comparison</h2>
    {table(compare, compare_cols, 30)}
  </div>
</div>
</body>
</html>"""
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(html_doc)


def print_console_summary(analysis: Dict[str, Any]) -> None:
    summary = analysis["summary"]
    print("SMF Cost & Activity Analyzer V2")
    print("-" * 72)
    print(f"Profile            : {analysis.get('profile')}")
    print(f"Records            : {summary.get('record_count')}")
    print(f"Record types       : {summary.get('record_types')}")
    print(f"Jobs               : {summary.get('jobs')}")
    print(f"Users              : {summary.get('users')}")
    print(f"Applications       : {summary.get('applications')}")
    print(f"Departments        : {summary.get('departments')}")
    print(f"CPU seconds        : {summary.get('cpu_seconds')}")
    print(f"zIIP seconds       : {summary.get('ziip_seconds')}")
    print(f"zIIP offload %     : {summary.get('ziip_offload_pct')}")
    print(f"Elapsed seconds    : {summary.get('elapsed_seconds')}")
    print(f"EXCP count         : {summary.get('excp_count')}")
    print(f"DB2 getpages       : {summary.get('db2_getpages')}")
    print(f"API requests       : {summary.get('api_requests')}")
    print(f"Estimated cost     : {summary.get('estimated_cost')}")
    print(f"Max RC             : {summary.get('max_rc')}")
    print(f"ABENDs             : {summary.get('abend_count')}")
    print(f"Issues             : {summary.get('issue_count')} (critical={summary.get('critical_count')}, error={summary.get('error_count')}, warning={summary.get('warning_count')})")
    print()
    print("TOP APPLICATIONS BY COST")
    print("-" * 72)
    for row in analysis.get("top_applications", [])[:10]:
        print(f"{row['key']:<18} cost={row['estimated_cost']:<10} cpu={row['cpu_seconds']:<10} elapsed={row['elapsed_seconds']:<10} issues_rc={row['max_rc']}")
    print()
    print("TOP FINDINGS")
    print("-" * 72)
    for finding in analysis.get("findings", [])[:12]:
        print(f"[{finding['severity']:<8}] {finding['code']:<28} {finding['entity_type']}={finding['entity_id']} value={finding['value']}")


def build_cost_model(args: argparse.Namespace) -> Dict[str, float]:
    model = dict(DEFAULT_COST_MODEL)
    for key in list(model.keys()):
        value = getattr(args, key, None)
        if value is not None:
            model[key] = value
    return model


def build_thresholds(args: argparse.Namespace) -> Dict[str, float]:
    thresholds = dict(DEFAULT_THRESHOLDS)
    for key in list(thresholds.keys()):
        value = getattr(args, key, None)
        if value is not None:
            thresholds[key] = value
    return thresholds


def add_cost_model_arguments(parser: argparse.ArgumentParser) -> None:
    group = parser.add_argument_group("cost model")
    for key, value in DEFAULT_COST_MODEL.items():
        group.add_argument(f"--{key.replace('_', '-')}", dest=key, type=float, default=None, help=f"Default: {value}")


def add_threshold_arguments(parser: argparse.ArgumentParser) -> None:
    group = parser.add_argument_group("thresholds")
    for key, value in DEFAULT_THRESHOLDS.items():
        group.add_argument(f"--{key.replace('_', '-')}", dest=key, type=float, default=None, help=f"Default: {value}")


def create_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="SMF Cost & Activity Analyzer V2 - normalized SMF CSV/JSONL analyzer")
    parser.add_argument("input_file", nargs="?", help="Normalized SMF export file, CSV/JSON/JSONL")
    parser.add_argument("--demo", action="store_true", help="Run with built-in demo records")
    parser.add_argument("--write-sample-csv", help="Write the current demo CSV and exit")
    parser.add_argument("--write-baseline-csv", help="Write the baseline demo CSV and exit")
    parser.add_argument("--compare-base", help="Baseline normalized SMF export for regression comparison")
    parser.add_argument("--profile", default="production", choices=["production", "batch", "api", "db2", "capacity", "chargeback", "training"], help="Analysis profile")
    parser.add_argument("--redact", action="store_true", help="Redact identifying values in output")
    parser.add_argument("--json", dest="json_output", help="Export full analysis JSON")
    parser.add_argument("--html", dest="html_output", help="Export HTML report")
    parser.add_argument("--csv-jobs", help="Export job aggregate CSV")
    parser.add_argument("--csv-users", help="Export user aggregate CSV")
    parser.add_argument("--csv-apps", help="Export application aggregate CSV")
    parser.add_argument("--csv-departments", help="Export department aggregate CSV")
    parser.add_argument("--csv-service-classes", help="Export service class aggregate CSV")
    parser.add_argument("--csv-hourly", help="Export hourly aggregate CSV")
    parser.add_argument("--csv-findings", help="Export findings CSV")
    parser.add_argument("--csv-compare", help="Export baseline comparison CSV")
    parser.add_argument("--fail-on-critical", action="store_true", help="Return non-zero exit code if critical findings exist")
    parser.add_argument("--fail-on-error", action="store_true", help="Return non-zero exit code if error or critical findings exist")
    add_cost_model_arguments(parser)
    add_threshold_arguments(parser)
    return parser


def analyze_records(records: List[SMFRecord], args: argparse.Namespace, cost_model: Dict[str, float], thresholds: Dict[str, float]) -> Dict[str, Any]:
    redactor = Redactor(args.redact)
    redacted_records = [redactor.record(rec) for rec in records]
    analyzer = SMFCostActivityAnalyzer(redacted_records, cost_model, thresholds, args.profile)
    analysis = analyzer.analyze()

    if args.compare_base:
        baseline_records = read_records(args.compare_base)
        baseline_records = [redactor.record(rec) for rec in baseline_records]
        baseline_analyzer = SMFCostActivityAnalyzer(baseline_records, cost_model, thresholds, args.profile)
        baseline_analysis = baseline_analyzer.analyze()
        compare_rows, compare_findings = compare_analyses(analysis, baseline_analysis, thresholds)
        analysis["baseline_compare"] = compare_rows
        analysis["findings"].extend([asdict(f) for f in compare_findings])
        analysis["summary"]["issue_count"] = len(analysis["findings"])
        analysis["summary"]["critical_count"] = sum(1 for f in analysis["findings"] if f.get("severity") == "CRITICAL")
        analysis["summary"]["error_count"] = sum(1 for f in analysis["findings"] if f.get("severity") == "ERROR")
        analysis["summary"]["warning_count"] = sum(1 for f in analysis["findings"] if f.get("severity") == "WARNING")
    else:
        analysis["baseline_compare"] = []
    return analysis


def main(argv: Optional[List[str]] = None) -> int:
    parser = create_arg_parser()
    args = parser.parse_args(argv)

    if args.write_sample_csv:
        write_sample_csv(args.write_sample_csv, SAMPLE_ROWS)
        print(f"Sample CSV written: {args.write_sample_csv}")
        return 0
    if args.write_baseline_csv:
        write_sample_csv(args.write_baseline_csv, BASELINE_ROWS)
        print(f"Baseline CSV written: {args.write_baseline_csv}")
        return 0

    if args.demo:
        records = [normalize_row(row) for row in SAMPLE_ROWS]
    elif args.input_file:
        records = read_records(args.input_file)
    else:
        parser.error("Provide input_file or use --demo")
        return 2

    cost_model = build_cost_model(args)
    thresholds = build_thresholds(args)
    analysis = analyze_records(records, args, cost_model, thresholds)

    print_console_summary(analysis)

    if args.json_output:
        write_json(args.json_output, analysis)
        print(f"JSON exported: {args.json_output}")
    if args.html_output:
        write_html_report(args.html_output, analysis)
        print(f"HTML exported: {args.html_output}")
    if args.csv_jobs:
        write_csv(args.csv_jobs, analysis.get("top_jobs", []))
        print(f"Jobs CSV exported: {args.csv_jobs}")
    if args.csv_users:
        write_csv(args.csv_users, analysis.get("top_users", []))
        print(f"Users CSV exported: {args.csv_users}")
    if args.csv_apps:
        write_csv(args.csv_apps, analysis.get("top_applications", []))
        print(f"Applications CSV exported: {args.csv_apps}")
    if args.csv_departments:
        write_csv(args.csv_departments, analysis.get("top_departments", []))
        print(f"Departments CSV exported: {args.csv_departments}")
    if args.csv_service_classes:
        write_csv(args.csv_service_classes, analysis.get("top_service_classes", []))
        print(f"Service classes CSV exported: {args.csv_service_classes}")
    if args.csv_hourly:
        write_csv(args.csv_hourly, analysis.get("hourly", []))
        print(f"Hourly CSV exported: {args.csv_hourly}")
    if args.csv_findings:
        write_csv(args.csv_findings, analysis.get("findings", []))
        print(f"Findings CSV exported: {args.csv_findings}")
    if args.csv_compare:
        write_csv(args.csv_compare, analysis.get("baseline_compare", []))
        print(f"Comparison CSV exported: {args.csv_compare}")

    critical = analysis["summary"].get("critical_count", 0)
    errors = analysis["summary"].get("error_count", 0)
    if args.fail_on_critical and critical:
        return 10
    if args.fail_on_error and (critical or errors):
        return 11
    return 0


if __name__ == "__main__":
    sys.exit(main())
