#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CICS Transaction Log Analyzer V2
Professional standalone analyzer for CICS transaction activity, logs, and monitoring exports.

Input formats:
- Plain text CICS logs containing DFH* messages and key=value activity lines.
- CSV transaction activity exports with flexible column aliases.
- Synthetic samples created with --demo or --write-sample.

Output formats:
- Console executive summary
- HTML report
- JSON analysis
- CSV events, transactions, programs, users, regions, resources, issues, clusters, hourly, compare

Design goal:
- Not a replacement for CICS Performance Analyzer, SMF 110 tooling, or OMEGAMON.
- A pragmatic offline triage layer for response time, ABENDs, DB2 wait, file control wait, security,
  storage, MQ, transient data, temporary storage, and transaction hotspots.
"""

from __future__ import annotations

import argparse
import csv
import html
import json
import math
import re
import statistics
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

VERSION = "2.0.0"

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

STATUS_ORDER = {
    "OK": 0,
    "ATTENTION": 1,
    "DEGRADED": 2,
    "FAILED": 3,
    "HIGH_RISK": 4,
}

CICS_FAMILY_CATALOG = {
    "DFHAC": ("abend", "Transaction abend / abnormal termination"),
    "DFHAP": ("application", "Application domain"),
    "DFHSR": ("storage", "Storage recovery / program check / storage violation"),
    "DFHSM": ("storage_manager", "Storage manager"),
    "DFHFC": ("file_control", "File control"),
    "DFHDB2": ("db2", "CICS DB2 attachment"),
    "DFHDB": ("db2", "DB2 related message"),
    "DFHXS": ("security", "CICS security"),
    "DFHUS": ("security", "User security"),
    "DFHXM": ("transaction_manager", "Transaction manager"),
    "DFHTS": ("temporary_storage", "Temporary storage"),
    "DFHTD": ("transient_data", "Transient data"),
    "DFHMQ": ("mq", "MQ bridge / MQ adapter"),
    "DFHPI": ("pipeline_api", "Pipeline / web services"),
    "DFHIS": ("ip_interconnectivity", "IP interconnectivity"),
    "DFHSO": ("sockets", "Sockets"),
    "DFHDU": ("dump", "Dump domain"),
    "DFHRM": ("recovery_manager", "Recovery manager"),
    "DFHDS": ("dispatcher", "Dispatcher"),
    "DFHKE": ("kernel", "Kernel"),
    "DFHLD": ("loader", "Loader"),
    "DFHFCW": ("file_control", "File control wait"),
    "DFHTR": ("trace", "Trace"),
    "DFHLG": ("log_manager", "Log manager"),
}

ABEND_CATALOG = {
    "ASRA": {
        "title": "Program check / storage or data issue",
        "area": "Application program / storage / data layout",
        "recommendation": "Review the transaction dump, program offsets, storage references, commarea layout, and recent program or copybook changes.",
        "severity": "CRITICAL",
    },
    "ASRB": {
        "title": "Program interrupt not handled cleanly",
        "area": "Application program / abnormal task termination",
        "recommendation": "Review dump analysis, CICS messages around the task, and application recovery routines.",
        "severity": "CRITICAL",
    },
    "AEI0": {
        "title": "EXEC CICS command error",
        "area": "Command-level CICS / EIBRESP",
        "recommendation": "Review EIBRESP/EIBRESP2, command parameters, resource names, and RESP handling in the program.",
        "severity": "ERROR",
    },
    "AEY9": {
        "title": "Security or command authorization issue",
        "area": "CICS security / RACF / resource authorization",
        "recommendation": "Check transaction, program, file, TDQ/TSQ, or resource authorization for the user and group.",
        "severity": "CRITICAL",
    },
    "APCT": {
        "title": "Program not found, disabled, or unavailable",
        "area": "Program definition / loader / library",
        "recommendation": "Check program definition, CSD/resource definitions, load library, NEWCOPY status, and autoinstall behavior.",
        "severity": "CRITICAL",
    },
    "AKCS": {
        "title": "Task purged or timeout condition",
        "area": "Task lifecycle / timeout / purge",
        "recommendation": "Check purge requests, transaction timeout settings, runaway task limits, lock waits, and operator actions.",
        "severity": "ERROR",
    },
    "AICA": {
        "title": "Runaway task or excessive CPU",
        "area": "CPU loop / runaway task",
        "recommendation": "Check loops, cursor scans, file browse patterns, runaway task interval, and recent application changes.",
        "severity": "CRITICAL",
    },
    "ATNI": {
        "title": "Terminal-related abnormal condition",
        "area": "Terminal / network / session",
        "recommendation": "Check terminal state, session drops, network path, and terminal control messages.",
        "severity": "WARNING",
    },
    "AEXZ": {
        "title": "Resource unavailable or command failure",
        "area": "Resource availability / EXEC CICS command",
        "recommendation": "Check resource state, command response codes, file/queue availability, and program RESP handling.",
        "severity": "ERROR",
    },
}

DEFAULT_POLICY = {
    "critical_transactions": [],
    "critical_programs": [],
    "sensitive_users": [],
    "sensitive_resources": ["PAY", "PAYROLL", "CARD", "AUTH", "SEC", "ADMIN", "CUSTOMER"],
    "watched_regions": [],
    "transaction_slo_ms": {},
    "program_slo_ms": {},
    "resource_slo_ms": {},
    "banned_transactions": [],
}

PROFILE_DEFAULTS = {
    "production": {
        "warn_response_ms": 1000.0,
        "error_response_ms": 3000.0,
        "critical_response_ms": 8000.0,
        "warn_db2_wait_ms": 500.0,
        "critical_db2_wait_ms": 3000.0,
        "warn_fc_wait_ms": 500.0,
        "critical_fc_wait_ms": 2500.0,
        "warn_cpu_ms": 700.0,
        "critical_cpu_ms": 3000.0,
    },
    "cics": {
        "warn_response_ms": 750.0,
        "error_response_ms": 2500.0,
        "critical_response_ms": 6000.0,
        "warn_db2_wait_ms": 400.0,
        "critical_db2_wait_ms": 2500.0,
        "warn_fc_wait_ms": 400.0,
        "critical_fc_wait_ms": 2500.0,
        "warn_cpu_ms": 600.0,
        "critical_cpu_ms": 2500.0,
    },
    "db2": {
        "warn_response_ms": 1000.0,
        "error_response_ms": 3000.0,
        "critical_response_ms": 8000.0,
        "warn_db2_wait_ms": 300.0,
        "critical_db2_wait_ms": 1500.0,
        "warn_fc_wait_ms": 900.0,
        "critical_fc_wait_ms": 4000.0,
        "warn_cpu_ms": 900.0,
        "critical_cpu_ms": 3500.0,
    },
    "file_control": {
        "warn_response_ms": 1000.0,
        "error_response_ms": 3000.0,
        "critical_response_ms": 8000.0,
        "warn_db2_wait_ms": 900.0,
        "critical_db2_wait_ms": 4000.0,
        "warn_fc_wait_ms": 300.0,
        "critical_fc_wait_ms": 1500.0,
        "warn_cpu_ms": 900.0,
        "critical_cpu_ms": 3500.0,
    },
    "security": {
        "warn_response_ms": 2000.0,
        "error_response_ms": 5000.0,
        "critical_response_ms": 10000.0,
        "warn_db2_wait_ms": 1000.0,
        "critical_db2_wait_ms": 5000.0,
        "warn_fc_wait_ms": 1000.0,
        "critical_fc_wait_ms": 5000.0,
        "warn_cpu_ms": 1000.0,
        "critical_cpu_ms": 5000.0,
    },
    "training": {
        "warn_response_ms": 3000.0,
        "error_response_ms": 8000.0,
        "critical_response_ms": 15000.0,
        "warn_db2_wait_ms": 2000.0,
        "critical_db2_wait_ms": 8000.0,
        "warn_fc_wait_ms": 2000.0,
        "critical_fc_wait_ms": 8000.0,
        "warn_cpu_ms": 2000.0,
        "critical_cpu_ms": 8000.0,
    },
    "strict": {
        "warn_response_ms": 500.0,
        "error_response_ms": 1500.0,
        "critical_response_ms": 4000.0,
        "warn_db2_wait_ms": 250.0,
        "critical_db2_wait_ms": 1200.0,
        "warn_fc_wait_ms": 250.0,
        "critical_fc_wait_ms": 1200.0,
        "warn_cpu_ms": 350.0,
        "critical_cpu_ms": 1500.0,
    },
}

COLUMN_ALIASES = {
    "timestamp": ["timestamp", "time", "datetime", "date_time"],
    "region": ["region", "applid", "cics_region", "system"],
    "tranid": ["tranid", "transaction", "transid", "tran", "tx"],
    "task": ["task", "taskid", "task_id"],
    "user": ["userid", "user", "user_id"],
    "terminal": ["terminal", "term", "termid", "terminal_id"],
    "program": ["program", "pgm", "program_name"],
    "response_ms": ["response_ms", "resp_ms", "response", "resp", "elapsed_ms"],
    "cpu_ms": ["cpu_ms", "cpu", "cpu_time_ms"],
    "db2_wait_ms": ["db2_wait_ms", "db2wait", "db2_wait", "db2_ms"],
    "fc_wait_ms": ["fc_wait_ms", "fcwait", "file_wait", "file_control_wait_ms"],
    "mq_wait_ms": ["mq_wait_ms", "mqwait", "mq_wait"],
    "ts_wait_ms": ["ts_wait_ms", "tswait", "temporary_storage_wait_ms"],
    "td_wait_ms": ["td_wait_ms", "tdwait", "transient_data_wait_ms"],
    "abend_code": ["abend", "abend_code", "abendcode"],
    "status": ["status", "result"],
    "file_name": ["file", "filename", "file_name", "resource"],
    "db2_plan": ["db2_plan", "plan"],
    "db2_package": ["db2_package", "package", "pkg"],
}


@dataclass
class AnalyzerConfig:
    profile: str = "production"
    warn_response_ms: float = 1000.0
    error_response_ms: float = 3000.0
    critical_response_ms: float = 8000.0
    warn_db2_wait_ms: float = 500.0
    critical_db2_wait_ms: float = 3000.0
    warn_fc_wait_ms: float = 500.0
    critical_fc_wait_ms: float = 2500.0
    warn_cpu_ms: float = 700.0
    critical_cpu_ms: float = 3000.0
    warn_abend_rate_pct: float = 1.0
    critical_abend_rate_pct: float = 5.0
    cluster_window_seconds: int = 120
    top_limit: int = 20
    context_lines: int = 2
    fail_on: str = ""
    redact: bool = False
    policy: Dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_POLICY))


@dataclass
class Event:
    line_no: int
    timestamp: str = ""
    timestamp_iso: str = ""
    region: str = ""
    tranid: str = ""
    task: str = ""
    user: str = ""
    terminal: str = ""
    program: str = ""
    message_id: str = ""
    family: str = "unknown"
    severity: str = "INFO"
    event_type: str = "message"
    status: str = ""
    response_ms: Optional[float] = None
    cpu_ms: Optional[float] = None
    db2_wait_ms: Optional[float] = None
    fc_wait_ms: Optional[float] = None
    mq_wait_ms: Optional[float] = None
    ts_wait_ms: Optional[float] = None
    td_wait_ms: Optional[float] = None
    suspend_ms: Optional[float] = None
    dispatch_ms: Optional[float] = None
    abend_code: str = ""
    file_name: str = ""
    queue_name: str = ""
    resource: str = ""
    db2_plan: str = ""
    db2_package: str = ""
    db2_sqlcode: str = ""
    eibresp: str = ""
    eibresp2: str = ""
    raw: str = ""


@dataclass
class Issue:
    severity: str
    code: str
    title: str
    area: str
    recommendation: str
    line_no: int = 0
    tranid: str = ""
    task: str = ""
    program: str = ""
    region: str = ""
    resource: str = ""
    metric_name: str = ""
    metric_value: Optional[float] = None
    evidence: List[str] = field(default_factory=list)


@dataclass
class TransactionSummary:
    tranid: str
    count: int = 0
    abend_count: int = 0
    max_response_ms: float = 0.0
    p95_response_ms: float = 0.0
    avg_response_ms: float = 0.0
    total_response_ms: float = 0.0
    max_cpu_ms: float = 0.0
    avg_cpu_ms: float = 0.0
    total_cpu_ms: float = 0.0
    max_db2_wait_ms: float = 0.0
    avg_db2_wait_ms: float = 0.0
    total_db2_wait_ms: float = 0.0
    max_fc_wait_ms: float = 0.0
    avg_fc_wait_ms: float = 0.0
    total_fc_wait_ms: float = 0.0
    total_mq_wait_ms: float = 0.0
    total_ts_wait_ms: float = 0.0
    total_td_wait_ms: float = 0.0
    users: List[str] = field(default_factory=list)
    terminals: List[str] = field(default_factory=list)
    programs: List[str] = field(default_factory=list)
    regions: List[str] = field(default_factory=list)
    abends: List[str] = field(default_factory=list)
    files: List[str] = field(default_factory=list)
    likely_area: str = "unknown"
    risk_score: int = 0


@dataclass
class ProgramSummary:
    program: str
    transaction_count: int = 0
    tranids: List[str] = field(default_factory=list)
    regions: List[str] = field(default_factory=list)
    abend_count: int = 0
    max_response_ms: float = 0.0
    avg_response_ms: float = 0.0
    total_cpu_ms: float = 0.0
    total_db2_wait_ms: float = 0.0
    total_fc_wait_ms: float = 0.0
    risk_score: int = 0


@dataclass
class UserSummary:
    user: str
    transaction_count: int = 0
    tranids: List[str] = field(default_factory=list)
    terminals: List[str] = field(default_factory=list)
    regions: List[str] = field(default_factory=list)
    abend_count: int = 0
    max_response_ms: float = 0.0
    avg_response_ms: float = 0.0
    risk_score: int = 0


@dataclass
class RegionSummary:
    region: str
    event_count: int = 0
    transaction_count: int = 0
    issue_count: int = 0
    abend_count: int = 0
    max_response_ms: float = 0.0
    avg_response_ms: float = 0.0
    families: List[str] = field(default_factory=list)
    risk_score: int = 0


@dataclass
class ResourceSummary:
    resource: str
    resource_type: str
    event_count: int = 0
    transaction_count: int = 0
    issue_count: int = 0
    max_wait_ms: float = 0.0
    total_wait_ms: float = 0.0
    tranids: List[str] = field(default_factory=list)
    programs: List[str] = field(default_factory=list)
    risk_score: int = 0


@dataclass
class Cluster:
    cluster_id: str
    start_time: str
    end_time: str
    duration_seconds: float
    event_count: int
    issue_count: int
    highest_severity: str
    regions: List[str] = field(default_factory=list)
    tranids: List[str] = field(default_factory=list)
    programs: List[str] = field(default_factory=list)
    families: List[str] = field(default_factory=list)
    root_cause_hint: str = ""
    risk_score: int = 0


@dataclass
class HourBucket:
    hour: str
    event_count: int = 0
    transaction_count: int = 0
    issue_count: int = 0
    abend_count: int = 0
    max_response_ms: float = 0.0
    avg_response_ms: float = 0.0


@dataclass
class CompareRow:
    entity_type: str
    entity_name: str
    metric: str
    baseline_value: float
    current_value: float
    delta: float
    delta_pct: float
    severity: str


@dataclass
class AnalysisResult:
    version: str
    source_name: str
    profile: str
    status: str
    highest_severity: str
    risk_score: int
    line_count: int
    event_count: int
    issue_count: int
    transaction_count: int
    program_count: int
    user_count: int
    region_count: int
    resource_count: int
    cluster_count: int
    abend_count: int
    events: List[Event]
    issues: List[Issue]
    transactions: List[TransactionSummary]
    programs: List[ProgramSummary]
    users: List[UserSummary]
    regions: List[RegionSummary]
    resources: List[ResourceSummary]
    clusters: List[Cluster]
    hourly: List[HourBucket]
    compare: List[CompareRow]


DEMO_LOGS = {
    "mixed": """\
2026-05-22 08:53:37 REGION=CICSPROD DFHXM0001 TRANID=PAY1 TASK=000124 USERID=USR001 TERM=TRM1 PROGRAM=PAYMAIN STARTED
2026-05-22 08:53:38 REGION=CICSPROD TRANID=PAY1 TASK=000124 USERID=USR001 TERM=TRM1 PROGRAM=PAYMAIN RESPONSE=2850MS CPU=230MS DB2WAIT=1900MS FCWAIT=120MS STATUS=OK
2026-05-22 08:53:40 REGION=CICSPROD TRANID=PAY1 TASK=000125 USERID=USR002 TERM=TRM2 PROGRAM=PAYMAIN RESPONSE=14200MS CPU=390MS DB2WAIT=12100MS FCWAIT=250MS STATUS=OK
2026-05-22 08:53:42 REGION=CICSPROD DFHDB2010 TRANID=PAY1 TASK=000125 USERID=USR002 PROGRAM=PAYMAIN DB2WAIT=12100MS PACKAGE=PAYPKG01 PLAN=PAYPLAN SQLCODE=-911 DB2 wait time high
2026-05-22 08:53:45 REGION=CICSPROD TRANID=PAY2 TASK=000126 USERID=USR003 TERM=TRM3 PROGRAM=PAYFILE RESPONSE=6200MS CPU=180MS DB2WAIT=50MS FCWAIT=5400MS FILE=PAYMSTR STATUS=OK
2026-05-22 08:53:46 REGION=CICSPROD DFHFC0951 TRANID=PAY2 TASK=000126 PROGRAM=PAYFILE FILE=PAYMSTR FCWAIT=5400MS File control wait threshold exceeded
2026-05-22 08:53:47 REGION=CICSPROD TRANID=PAY3 TASK=000127 USERID=USR004 TERM=TRM4 PROGRAM=PAYBAD RESPONSE=600MS CPU=410MS DB2WAIT=20MS FCWAIT=30MS ABEND=ASRA STATUS=ABEND
2026-05-22 08:53:47 REGION=CICSPROD DFHAC2206 Transaction PAY3 abend ASRA in program PAYBAD task 000127 terminal TRM4
2026-05-22 08:53:48 REGION=CICSPROD DFHSR0001 TRANID=PAY3 TASK=000127 Storage violation suspected near PAYBAD
2026-05-22 08:54:01 REGION=CICSPROD TRANID=ORD1 TASK=000128 USERID=USR005 TERM=TRM5 PROGRAM=ORDMAIN RESPONSE=420MS CPU=90MS DB2WAIT=50MS FCWAIT=25MS STATUS=OK
2026-05-22 08:54:05 REGION=CICSPROD TRANID=PAY1 TASK=000129 USERID=USR001 TERM=TRM1 PROGRAM=PAYMAIN RESPONSE=3550MS CPU=250MS DB2WAIT=2700MS FCWAIT=130MS STATUS=OK
2026-05-22 08:54:10 REGION=CICSPROD TRANID=PAY4 TASK=000130 USERID=USR006 TERM=TRM6 PROGRAM=PAYSEC RESPONSE=300MS CPU=60MS DB2WAIT=10MS FCWAIT=20MS ABEND=AEY9 STATUS=ABEND RESOURCE=PAY.ADMIN
2026-05-22 08:54:10 REGION=CICSPROD DFHAC2206 Transaction PAY4 abend AEY9 in program PAYSEC task 000130 terminal TRM6
2026-05-22 08:54:11 REGION=CICSPROD DFHXS1111 TRANID=PAY4 USERID=USR006 Security authorization failure for resource PAY.ADMIN
2026-05-22 08:54:15 REGION=CICSPROD DFHMQ0120 TRANID=ORD2 TASK=000131 PROGRAM=ORDMQ MQWAIT=3200MS QUEUE=ORDER.REQUEST MQ queue wait high
2026-05-22 08:54:19 REGION=CICSPROD TRANID=ORD2 TASK=000131 USERID=USR007 TERM=TRM7 PROGRAM=ORDMQ RESPONSE=4100MS CPU=120MS MQWAIT=3200MS STATUS=OK
""",
    "db2": """\
2026-05-22 09:01:01 REGION=CICSPROD TRANID=INQ1 TASK=100001 USERID=USR011 PROGRAM=CUSTINQ RESPONSE=980MS CPU=80MS DB2WAIT=600MS STATUS=OK
2026-05-22 09:01:03 REGION=CICSPROD DFHDB2010 TRANID=INQ1 TASK=100001 PROGRAM=CUSTINQ PACKAGE=CUSTPKG PLAN=CUSTPLAN SQLCODE=0 DB2WAIT=600MS DB2 wait elevated
2026-05-22 09:01:06 REGION=CICSPROD TRANID=INQ1 TASK=100002 USERID=USR012 PROGRAM=CUSTINQ RESPONSE=8200MS CPU=180MS DB2WAIT=7600MS STATUS=OK
2026-05-22 09:01:07 REGION=CICSPROD DFHDB2010 TRANID=INQ1 TASK=100002 PROGRAM=CUSTINQ PACKAGE=CUSTPKG PLAN=CUSTPLAN SQLCODE=-911 DB2WAIT=7600MS DB2 deadlock or timeout suspected
2026-05-22 09:01:08 REGION=CICSPROD TRANID=UPD1 TASK=100003 USERID=USR013 PROGRAM=CUSTUPD RESPONSE=9300MS CPU=220MS DB2WAIT=8400MS STATUS=OK
""",
    "file": """\
2026-05-22 10:10:01 REGION=CICSPROD TRANID=FIL1 TASK=200001 USERID=USR021 PROGRAM=FILEINQ RESPONSE=1500MS CPU=110MS FCWAIT=1100MS FILE=CUSTFILE STATUS=OK
2026-05-22 10:10:02 REGION=CICSPROD DFHFC0951 TRANID=FIL1 TASK=200001 PROGRAM=FILEINQ FILE=CUSTFILE FCWAIT=1100MS File wait warning
2026-05-22 10:10:05 REGION=CICSPROD TRANID=FIL1 TASK=200002 USERID=USR022 PROGRAM=FILEINQ RESPONSE=9800MS CPU=190MS FCWAIT=9000MS FILE=CUSTFILE STATUS=OK
2026-05-22 10:10:06 REGION=CICSPROD DFHFC0951 TRANID=FIL1 TASK=200002 PROGRAM=FILEINQ FILE=CUSTFILE FCWAIT=9000MS File control wait critical
""",
    "security": """\
2026-05-22 11:22:01 REGION=CICSPROD TRANID=ADM1 TASK=300001 USERID=USR999 PROGRAM=ADMTOOL RESPONSE=350MS CPU=70MS RESOURCE=PAY.ADMIN ABEND=AEY9 STATUS=ABEND
2026-05-22 11:22:01 REGION=CICSPROD DFHAC2206 Transaction ADM1 abend AEY9 in program ADMTOOL task 300001 terminal ADM1
2026-05-22 11:22:02 REGION=CICSPROD DFHXS1111 TRANID=ADM1 USERID=USR999 Security authorization failure for resource PAY.ADMIN
2026-05-22 11:22:03 REGION=CICSPROD DFHUS0002 USERID=USR999 unauthorized access to transaction ADM1
""",
}

BASELINE_LOG = """\
2026-05-21 08:53:38 REGION=CICSPROD TRANID=PAY1 TASK=990001 USERID=USR001 TERM=TRM1 PROGRAM=PAYMAIN RESPONSE=1200MS CPU=210MS DB2WAIT=650MS FCWAIT=90MS STATUS=OK
2026-05-21 08:53:45 REGION=CICSPROD TRANID=PAY2 TASK=990002 USERID=USR003 TERM=TRM3 PROGRAM=PAYFILE RESPONSE=1700MS CPU=160MS DB2WAIT=40MS FCWAIT=1200MS STATUS=OK
2026-05-21 08:54:01 REGION=CICSPROD TRANID=ORD1 TASK=990003 USERID=USR005 TERM=TRM5 PROGRAM=ORDMAIN RESPONSE=390MS CPU=80MS DB2WAIT=50MS FCWAIT=20MS STATUS=OK
2026-05-21 08:54:15 REGION=CICSPROD TRANID=ORD2 TASK=990004 USERID=USR007 TERM=TRM7 PROGRAM=ORDMQ RESPONSE=800MS CPU=110MS MQWAIT=300MS STATUS=OK
"""


def unique_sorted(values: Iterable[str]) -> List[str]:
    return sorted({v for v in values if v})


def as_float(value: Any) -> Optional[float]:
    if value is None:
        return None
    text = str(value).strip().upper().replace(",", "")
    if not text or text in {"-", "N/A", "NONE", "NULL"}:
        return None
    multiplier = 1.0
    if text.endswith("MS"):
        text = text[:-2]
    elif text.endswith("SEC"):
        text = text[:-3]
        multiplier = 1000.0
    elif text.endswith("S") and re.match(r"^[0-9.]+S$", text):
        text = text[:-1]
        multiplier = 1000.0
    try:
        return float(text) * multiplier
    except ValueError:
        return None


def parse_timestamp(text: str) -> Tuple[str, str]:
    patterns = [
        r"(?P<ts>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)",
        r"(?P<ts>\d{2}/\d{2}/\d{4}\s+\d{2}:\d{2}:\d{2})",
        r"(?P<ts>\d{2}:\d{2}:\d{2}(?:\.\d+)?)",
    ]
    for pat in patterns:
        match = re.search(pat, text)
        if not match:
            continue
        raw = match.group("ts")
        for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %H:%M:%S", "%H:%M:%S.%f", "%H:%M:%S"):
            try:
                dt = datetime.strptime(raw, fmt)
                if fmt.startswith("%H"):
                    dt = dt.replace(year=1900, month=1, day=1)
                return raw, dt.isoformat()
            except ValueError:
                continue
        return raw, ""
    return "", ""


def dt_from_iso(value: str) -> Optional[datetime]:
    if not value:
        return None
    try:
        return datetime.fromisoformat(value)
    except ValueError:
        return None


def normalize_key(key: str) -> str:
    return key.strip().lower().replace("-", "_").replace(" ", "_")


def extract_key_values(line: str) -> Dict[str, str]:
    kv: Dict[str, str] = {}
    pattern = re.compile(r"\b([A-Za-z][A-Za-z0-9_\-]*)\s*=\s*([^\s,;]+)")
    for match in pattern.finditer(line):
        kv[normalize_key(match.group(1))] = match.group(2).strip().strip("'\"")
    return kv


def find_first(kv: Dict[str, str], names: Iterable[str]) -> str:
    for name in names:
        key = normalize_key(name)
        if key in kv and kv[key] != "":
            return kv[key]
    return ""


def csv_alias_get(row: Dict[str, str], canonical: str) -> str:
    lookup = {normalize_key(k): v for k, v in row.items()}
    for alias in COLUMN_ALIASES.get(canonical, [canonical]):
        key = normalize_key(alias)
        if key in lookup:
            return lookup[key]
    return ""


def detect_message_id(line: str) -> str:
    match = re.search(r"\b(DFH[A-Z0-9]{2,5}\d{0,4}|DFH[A-Z]{2}\d{4})\b", line, re.IGNORECASE)
    if match:
        return match.group(1).upper()
    return ""


def classify_family(message_id: str, line: str) -> str:
    msg = message_id.upper()
    for prefix, (family, _) in CICS_FAMILY_CATALOG.items():
        if msg.startswith(prefix):
            return family
    upper = line.upper()
    if "DB2" in upper or "SQLCODE" in upper:
        return "db2"
    if "FILE" in upper or "FCWAIT" in upper:
        return "file_control"
    if "SECURITY" in upper or "AUTH" in upper or "RACF" in upper:
        return "security"
    if "MQ" in upper or "QUEUE" in upper:
        return "mq"
    if "TEMPORARY STORAGE" in upper or "TSQ" in upper:
        return "temporary_storage"
    if "TRANSIENT DATA" in upper or "TDQ" in upper:
        return "transient_data"
    return "unknown"


def detect_abend(line: str, kv: Dict[str, str]) -> str:
    for key in ("abend", "abend_code", "abendcode"):
        if key in kv:
            return kv[key].strip().upper().replace(",", "")
    match = re.search(r"\b(?:ABEND|ABENDED|ABND)\s*[= ]\s*([A-Z][A-Z0-9]{3})\b", line, re.IGNORECASE)
    if match:
        return match.group(1).upper()
    match = re.search(r"\babend\s+([A-Z][A-Z0-9]{3})\b", line, re.IGNORECASE)
    if match:
        return match.group(1).upper()
    return ""


def detect_resource(line: str, kv: Dict[str, str]) -> str:
    for key in ("resource", "file", "queue", "tsq", "tdq", "dataset"):
        if key in kv and kv[key]:
            return kv[key]
    match = re.search(r"\b(resource|file|queue|tsq|tdq)\s+([A-Z0-9.$#@_\-]+)\b", line, re.IGNORECASE)
    if match:
        return match.group(2)
    return ""


def infer_severity(event: Event, config: AnalyzerConfig) -> str:
    sev = "INFO"
    upper = event.raw.upper()
    if event.abend_code:
        sev = max_sev(sev, ABEND_CATALOG.get(event.abend_code, {}).get("severity", "ERROR"))
    if event.response_ms is not None:
        if event.response_ms >= config.critical_response_ms:
            sev = max_sev(sev, "CRITICAL")
        elif event.response_ms >= config.error_response_ms:
            sev = max_sev(sev, "ERROR")
        elif event.response_ms >= config.warn_response_ms:
            sev = max_sev(sev, "WARNING")
    if event.db2_wait_ms is not None:
        if event.db2_wait_ms >= config.critical_db2_wait_ms:
            sev = max_sev(sev, "CRITICAL")
        elif event.db2_wait_ms >= config.warn_db2_wait_ms:
            sev = max_sev(sev, "WARNING")
    if event.fc_wait_ms is not None:
        if event.fc_wait_ms >= config.critical_fc_wait_ms:
            sev = max_sev(sev, "CRITICAL")
        elif event.fc_wait_ms >= config.warn_fc_wait_ms:
            sev = max_sev(sev, "WARNING")
    if event.cpu_ms is not None:
        if event.cpu_ms >= config.critical_cpu_ms:
            sev = max_sev(sev, "CRITICAL")
        elif event.cpu_ms >= config.warn_cpu_ms:
            sev = max_sev(sev, "WARNING")
    if any(w in upper for w in ("SECURITY", "AUTHORIZATION FAILURE", "NOT AUTHORIZED", "UNAUTHORIZED")):
        sev = max_sev(sev, "CRITICAL")
    if any(w in upper for w in ("STORAGE VIOLATION", "RUNAWAY", "PURGED", "DEADLOCK", "TIMEOUT")):
        sev = max_sev(sev, "ERROR")
    if any(w in upper for w in ("ERROR", "FAILED", "FAILURE", "EXCEPTION")):
        sev = max_sev(sev, "ERROR")
    return sev


def max_sev(a: str, b: str) -> str:
    return a if SEVERITY_ORDER.get(a, 0) >= SEVERITY_ORDER.get(b, 0) else b


def percentile(values: List[float], pct: float) -> float:
    if not values:
        return 0.0
    values = sorted(values)
    if len(values) == 1:
        return values[0]
    k = (len(values) - 1) * (pct / 100.0)
    f = math.floor(k)
    c = math.ceil(k)
    if f == c:
        return values[int(k)]
    return values[f] * (c - k) + values[c] * (k - f)


def score_from_ratio(value: float, threshold: float, max_points: int) -> int:
    if threshold <= 0:
        return 0
    return int(min(max_points, max_points * (value / (threshold * 2))))


def load_policy(path: str) -> Dict[str, Any]:
    policy = dict(DEFAULT_POLICY)
    if not path:
        return policy
    with open(path, "r", encoding="utf-8") as fh:
        loaded = json.load(fh)
    if not isinstance(loaded, dict):
        return policy
    for key, value in loaded.items():
        policy[key] = value
    return policy


def build_config(args: argparse.Namespace) -> AnalyzerConfig:
    profile = args.profile or "production"
    defaults = PROFILE_DEFAULTS.get(profile, PROFILE_DEFAULTS["production"])
    policy = load_policy(args.custom_policy) if args.custom_policy else dict(DEFAULT_POLICY)
    for key in ("thresholds",):
        if isinstance(policy.get(key), dict):
            defaults = {**defaults, **policy[key]}
    config = AnalyzerConfig(profile=profile, policy=policy)
    for key, value in defaults.items():
        setattr(config, key, value)
    for key in [
        "warn_response_ms", "error_response_ms", "critical_response_ms",
        "warn_db2_wait_ms", "critical_db2_wait_ms", "warn_fc_wait_ms", "critical_fc_wait_ms",
        "warn_cpu_ms", "critical_cpu_ms", "cluster_window_seconds", "context_lines", "top_limit"
    ]:
        value = getattr(args, key, None)
        if value is not None:
            setattr(config, key, value)
    config.redact = bool(args.redact)
    config.fail_on = args.fail_on or ""
    return config


def parse_event_line(line: str, line_no: int, config: AnalyzerConfig) -> Optional[Event]:
    raw_line = line.rstrip("\n")
    if not raw_line.strip():
        return None
    timestamp, timestamp_iso = parse_timestamp(raw_line)
    kv = extract_key_values(raw_line)
    msg_id = detect_message_id(raw_line)
    event = Event(
        line_no=line_no,
        timestamp=timestamp,
        timestamp_iso=timestamp_iso,
        region=find_first(kv, ["region", "applid", "cics_region", "system"]),
        tranid=find_first(kv, ["tranid", "transaction", "transid", "tran"]),
        task=find_first(kv, ["task", "taskid", "task_id"]),
        user=find_first(kv, ["userid", "user", "user_id"]),
        terminal=find_first(kv, ["term", "termid", "terminal"]),
        program=find_first(kv, ["program", "pgm"]),
        message_id=msg_id,
        family=classify_family(msg_id, raw_line),
        status=find_first(kv, ["status", "result"]),
        response_ms=as_float(find_first(kv, ["response", "response_ms", "resp", "resp_ms", "elapsed", "elapsed_ms"])),
        cpu_ms=as_float(find_first(kv, ["cpu", "cpu_ms"])),
        db2_wait_ms=as_float(find_first(kv, ["db2wait", "db2_wait", "db2_wait_ms"])),
        fc_wait_ms=as_float(find_first(kv, ["fcwait", "fc_wait", "file_wait", "fc_wait_ms"])),
        mq_wait_ms=as_float(find_first(kv, ["mqwait", "mq_wait", "mq_wait_ms"])),
        ts_wait_ms=as_float(find_first(kv, ["tswait", "ts_wait", "ts_wait_ms"])),
        td_wait_ms=as_float(find_first(kv, ["tdwait", "td_wait", "td_wait_ms"])),
        suspend_ms=as_float(find_first(kv, ["suspend", "suspend_ms"])),
        dispatch_ms=as_float(find_first(kv, ["dispatch", "dispatch_ms"])),
        abend_code=detect_abend(raw_line, kv),
        file_name=find_first(kv, ["file", "filename", "file_name"]),
        queue_name=find_first(kv, ["queue", "qname", "mqqueue"]),
        resource=detect_resource(raw_line, kv),
        db2_plan=find_first(kv, ["plan", "db2plan", "db2_plan"]),
        db2_package=find_first(kv, ["package", "pkg", "db2_package"]),
        db2_sqlcode=find_first(kv, ["sqlcode", "sql_code"]),
        eibresp=find_first(kv, ["eibresp"]),
        eibresp2=find_first(kv, ["eibresp2"]),
        raw=raw_line,
    )
    if not event.tranid:
        m = re.search(r"\b(?:TRANSACTION|TRANID|TRANSID)\s+([A-Z0-9]{1,4})\b", raw_line, re.IGNORECASE)
        if m:
            event.tranid = m.group(1).upper()
    if not event.program:
        m = re.search(r"\bprogram\s+([A-Z0-9#$@_\-]{1,32})\b", raw_line, re.IGNORECASE)
        if m:
            event.program = m.group(1).upper()
    if not event.task:
        m = re.search(r"\btask\s+([0-9A-Z]+)\b", raw_line, re.IGNORECASE)
        if m:
            event.task = m.group(1).upper()
    if event.response_ms is not None or event.abend_code or event.status.upper() in {"OK", "ABEND", "FAILED", "ERROR"}:
        event.event_type = "transaction"
    elif event.message_id:
        event.event_type = "cics_message"
    elif event.family != "unknown":
        event.event_type = "message"
    event.severity = infer_severity(event, config)
    return event


def parse_csv(path: Path, config: AnalyzerConfig) -> List[Event]:
    events: List[Event] = []
    with path.open("r", encoding="utf-8", errors="ignore", newline="") as fh:
        reader = csv.DictReader(fh)
        for idx, row in enumerate(reader, start=2):
            raw = " ".join(f"{k}={v}" for k, v in row.items() if v)
            timestamp = csv_alias_get(row, "timestamp")
            timestamp_raw, timestamp_iso = parse_timestamp(timestamp or raw)
            event = Event(
                line_no=idx,
                timestamp=timestamp_raw or timestamp,
                timestamp_iso=timestamp_iso,
                region=csv_alias_get(row, "region"),
                tranid=csv_alias_get(row, "tranid"),
                task=csv_alias_get(row, "task"),
                user=csv_alias_get(row, "user"),
                terminal=csv_alias_get(row, "terminal"),
                program=csv_alias_get(row, "program"),
                message_id=detect_message_id(raw),
                status=csv_alias_get(row, "status"),
                response_ms=as_float(csv_alias_get(row, "response_ms")),
                cpu_ms=as_float(csv_alias_get(row, "cpu_ms")),
                db2_wait_ms=as_float(csv_alias_get(row, "db2_wait_ms")),
                fc_wait_ms=as_float(csv_alias_get(row, "fc_wait_ms")),
                mq_wait_ms=as_float(csv_alias_get(row, "mq_wait_ms")),
                ts_wait_ms=as_float(csv_alias_get(row, "ts_wait_ms")),
                td_wait_ms=as_float(csv_alias_get(row, "td_wait_ms")),
                abend_code=csv_alias_get(row, "abend_code").upper(),
                file_name=csv_alias_get(row, "file_name"),
                resource=csv_alias_get(row, "file_name"),
                db2_plan=csv_alias_get(row, "db2_plan"),
                db2_package=csv_alias_get(row, "db2_package"),
                raw=raw,
            )
            event.family = classify_family(event.message_id, raw)
            if event.response_ms is not None or event.abend_code or event.status:
                event.event_type = "transaction"
            event.severity = infer_severity(event, config)
            events.append(event)
    return events


def parse_input_text(text: str, config: AnalyzerConfig) -> List[Event]:
    events: List[Event] = []
    for line_no, line in enumerate(text.splitlines(), start=1):
        event = parse_event_line(line, line_no, config)
        if event:
            events.append(event)
    return events


def parse_input_path(path: Path, config: AnalyzerConfig) -> Tuple[List[Event], int]:
    if path.suffix.lower() == ".csv":
        events = parse_csv(path, config)
        return events, sum(1 for _ in path.open("r", encoding="utf-8", errors="ignore"))
    text = path.read_text(encoding="utf-8", errors="ignore")
    events = parse_input_text(text, config)
    return events, len(text.splitlines())


def evidence_window(events: List[Event], line_no: int, context: int) -> List[str]:
    if context <= 0:
        return []
    selected = [e.raw for e in events if line_no - context <= e.line_no <= line_no + context]
    return selected[: max(1, context * 2 + 1)]


def add_issue(issues: List[Issue], issue: Issue) -> None:
    issues.append(issue)


def derive_event_issues(events: List[Event], config: AnalyzerConfig) -> List[Issue]:
    issues: List[Issue] = []
    policy = config.policy
    sensitive_patterns = [p.upper() for p in policy.get("sensitive_resources", [])]
    critical_transactions = [p.upper() for p in policy.get("critical_transactions", [])]
    critical_programs = [p.upper() for p in policy.get("critical_programs", [])]
    banned_transactions = [p.upper() for p in policy.get("banned_transactions", [])]
    for event in events:
        evidence = evidence_window(events, event.line_no, config.context_lines)
        if event.abend_code:
            hint = ABEND_CATALOG.get(event.abend_code, {})
            add_issue(issues, Issue(
                severity=hint.get("severity", "ERROR"),
                code=f"CICS_ABEND_{event.abend_code}",
                title=f"Transaction abend {event.abend_code}: {hint.get('title', 'Unknown CICS abend')}",
                area=hint.get("area", "CICS transaction abend"),
                recommendation=hint.get("recommendation", "Review CICS dump, task context, resource definitions, and messages before the abend."),
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                resource=event.resource,
                evidence=evidence,
            ))
        if event.response_ms is not None:
            slo = config.policy.get("transaction_slo_ms", {}).get(event.tranid) if isinstance(config.policy.get("transaction_slo_ms"), dict) else None
            threshold = float(slo) if slo else config.critical_response_ms
            if event.response_ms >= threshold:
                add_issue(issues, Issue(
                    severity="CRITICAL" if event.response_ms >= config.critical_response_ms else "ERROR",
                    code="CICS_RESPONSE_TIME_HIGH",
                    title="High CICS transaction response time",
                    area="Transaction response time / service level",
                    recommendation="Correlate response time with DB2 wait, file control wait, CPU, MQ, TS/TD waits, and region-level contention.",
                    line_no=event.line_no,
                    tranid=event.tranid,
                    task=event.task,
                    program=event.program,
                    region=event.region,
                    metric_name="response_ms",
                    metric_value=event.response_ms,
                    evidence=evidence,
                ))
            elif event.response_ms >= config.warn_response_ms:
                add_issue(issues, Issue(
                    severity="WARNING",
                    code="CICS_RESPONSE_TIME_WARNING",
                    title="Elevated CICS transaction response time",
                    area="Transaction response time",
                    recommendation="Watch trend and compare with historical baseline or transaction SLO.",
                    line_no=event.line_no,
                    tranid=event.tranid,
                    task=event.task,
                    program=event.program,
                    region=event.region,
                    metric_name="response_ms",
                    metric_value=event.response_ms,
                    evidence=evidence,
                ))
        if event.db2_wait_ms is not None and event.db2_wait_ms >= config.warn_db2_wait_ms:
            add_issue(issues, Issue(
                severity="CRITICAL" if event.db2_wait_ms >= config.critical_db2_wait_ms else "WARNING",
                code="CICS_DB2_WAIT_HIGH",
                title="DB2 wait dominates or heavily contributes to response time",
                area="DB2 attachment / SQL / lock wait / package path",
                recommendation="Check DB2 accounting, lock waits, package access path, SQLCODE, thread availability, and recent DB2 changes.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                resource=event.db2_package or event.db2_plan,
                metric_name="db2_wait_ms",
                metric_value=event.db2_wait_ms,
                evidence=evidence,
            ))
        if event.fc_wait_ms is not None and event.fc_wait_ms >= config.warn_fc_wait_ms:
            add_issue(issues, Issue(
                severity="CRITICAL" if event.fc_wait_ms >= config.critical_fc_wait_ms else "WARNING",
                code="CICS_FILE_CONTROL_WAIT_HIGH",
                title="File control wait is elevated",
                area="VSAM / file control / string wait / I/O contention",
                recommendation="Check file string waits, VSAM CI/CA splits, LSR buffer pools, enqueue contention, and DASD latency.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                resource=event.file_name or event.resource,
                metric_name="fc_wait_ms",
                metric_value=event.fc_wait_ms,
                evidence=evidence,
            ))
        if event.cpu_ms is not None and event.cpu_ms >= config.warn_cpu_ms:
            add_issue(issues, Issue(
                severity="CRITICAL" if event.cpu_ms >= config.critical_cpu_ms else "WARNING",
                code="CICS_CPU_HIGH",
                title="High CPU time for transaction task",
                area="CPU / runaway candidate / loop / inefficient program path",
                recommendation="Check application loops, inefficient algorithms, DB2 fetch loops, file browse patterns, and runaway task settings.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                metric_name="cpu_ms",
                metric_value=event.cpu_ms,
                evidence=evidence,
            ))
        if event.mq_wait_ms is not None and event.mq_wait_ms >= config.warn_response_ms:
            add_issue(issues, Issue(
                severity="ERROR" if event.mq_wait_ms >= config.critical_response_ms else "WARNING",
                code="CICS_MQ_WAIT_HIGH",
                title="MQ wait is elevated",
                area="MQ bridge / queue wait / external dependency",
                recommendation="Check queue depth, MQ channel state, backend consumer latency, and CICS-MQ adapter messages.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                resource=event.queue_name or event.resource,
                metric_name="mq_wait_ms",
                metric_value=event.mq_wait_ms,
                evidence=evidence,
            ))
        if event.family == "security" or any(word in event.raw.upper() for word in ["SECURITY", "AUTHORIZATION", "UNAUTHORIZED", "NOT AUTHORIZED"]):
            add_issue(issues, Issue(
                severity="CRITICAL",
                code="CICS_SECURITY_EVENT",
                title="CICS security or authorization event detected",
                area="CICS security / RACF / resource protection",
                recommendation="Check transaction, program, file, queue, and resource-level permissions for the user and group.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                resource=event.resource,
                evidence=evidence,
            ))
        if event.family == "storage" or "STORAGE VIOLATION" in event.raw.upper():
            add_issue(issues, Issue(
                severity="CRITICAL" if "VIOLATION" in event.raw.upper() else "ERROR",
                code="CICS_STORAGE_EVENT",
                title="CICS storage event detected",
                area="Storage violation / storage manager / program memory overwrite",
                recommendation="Review transaction dump, storage protection settings, program pointer handling, commarea length, and recent copybook changes.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                evidence=evidence,
            ))
        if event.tranid.upper() in banned_transactions:
            add_issue(issues, Issue(
                severity="CRITICAL",
                code="CICS_BANNED_TRANSACTION_USED",
                title="Banned transaction detected by policy",
                area="Governance / security / change control",
                recommendation="Review why the transaction executed and validate operations policy.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                evidence=evidence,
            ))
        if event.tranid.upper() in critical_transactions and event.severity in {"WARNING", "ERROR", "CRITICAL"}:
            add_issue(issues, Issue(
                severity=max_sev(event.severity, "ERROR"),
                code="CICS_CRITICAL_TRANSACTION_IMPACTED",
                title="Critical transaction impacted",
                area="Business critical transaction",
                recommendation="Escalate to application owner and correlate with user impact and SLA.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                evidence=evidence,
            ))
        if event.program.upper() in critical_programs and event.severity in {"WARNING", "ERROR", "CRITICAL"}:
            add_issue(issues, Issue(
                severity=max_sev(event.severity, "ERROR"),
                code="CICS_CRITICAL_PROGRAM_IMPACTED",
                title="Critical program impacted",
                area="Business critical program",
                recommendation="Review program health, deployment status, recent changes, and dependent resources.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                evidence=evidence,
            ))
        resource_upper = (event.resource or event.file_name or event.queue_name or "").upper()
        if resource_upper and any(pattern in resource_upper for pattern in sensitive_patterns) and event.severity in {"WARNING", "ERROR", "CRITICAL"}:
            add_issue(issues, Issue(
                severity=max_sev(event.severity, "ERROR"),
                code="CICS_SENSITIVE_RESOURCE_IMPACTED",
                title="Sensitive resource involved in an abnormal event",
                area="Sensitive resource / business data / security",
                recommendation="Validate business impact, permissions, and resource state before retrying the workload.",
                line_no=event.line_no,
                tranid=event.tranid,
                task=event.task,
                program=event.program,
                region=event.region,
                resource=resource_upper,
                evidence=evidence,
            ))
    return issues


def summarize_transactions(events: List[Event], issues: List[Issue], config: AnalyzerConfig) -> List[TransactionSummary]:
    by: Dict[str, List[Event]] = {}
    for e in events:
        if e.tranid:
            by.setdefault(e.tranid, []).append(e)
    summaries: List[TransactionSummary] = []
    for tranid, rows in by.items():
        response_values = [e.response_ms for e in rows if e.response_ms is not None]
        cpu_values = [e.cpu_ms for e in rows if e.cpu_ms is not None]
        db2_values = [e.db2_wait_ms for e in rows if e.db2_wait_ms is not None]
        fc_values = [e.fc_wait_ms for e in rows if e.fc_wait_ms is not None]
        total_response = sum(response_values)
        total_cpu = sum(cpu_values)
        total_db2 = sum(db2_values)
        total_fc = sum(fc_values)
        total_mq = sum(e.mq_wait_ms or 0 for e in rows)
        total_ts = sum(e.ts_wait_ms or 0 for e in rows)
        total_td = sum(e.td_wait_ms or 0 for e in rows)
        abends = unique_sorted([e.abend_code for e in rows if e.abend_code])
        issue_count = len([i for i in issues if i.tranid == tranid])
        likely_area = infer_likely_area(total_db2, total_fc, total_mq, total_ts, total_td, total_cpu, abends)
        risk = min(100, issue_count * 10 + len(abends) * 25 + score_from_ratio(max(response_values or [0.0]), config.critical_response_ms, 35))
        summaries.append(TransactionSummary(
            tranid=tranid,
            count=len([e for e in rows if e.event_type == "transaction" or e.response_ms is not None or e.abend_code]),
            abend_count=len([e for e in rows if e.abend_code]),
            max_response_ms=max(response_values or [0.0]),
            p95_response_ms=percentile(response_values, 95),
            avg_response_ms=sum(response_values) / len(response_values) if response_values else 0.0,
            total_response_ms=total_response,
            max_cpu_ms=max(cpu_values or [0.0]),
            avg_cpu_ms=sum(cpu_values) / len(cpu_values) if cpu_values else 0.0,
            total_cpu_ms=total_cpu,
            max_db2_wait_ms=max(db2_values or [0.0]),
            avg_db2_wait_ms=sum(db2_values) / len(db2_values) if db2_values else 0.0,
            total_db2_wait_ms=total_db2,
            max_fc_wait_ms=max(fc_values or [0.0]),
            avg_fc_wait_ms=sum(fc_values) / len(fc_values) if fc_values else 0.0,
            total_fc_wait_ms=total_fc,
            total_mq_wait_ms=total_mq,
            total_ts_wait_ms=total_ts,
            total_td_wait_ms=total_td,
            users=unique_sorted(e.user for e in rows),
            terminals=unique_sorted(e.terminal for e in rows),
            programs=unique_sorted(e.program for e in rows),
            regions=unique_sorted(e.region for e in rows),
            abends=abends,
            files=unique_sorted(e.file_name or e.resource for e in rows),
            likely_area=likely_area,
            risk_score=risk,
        ))
    return sorted(summaries, key=lambda x: (x.risk_score, x.max_response_ms, x.abend_count), reverse=True)


def infer_likely_area(db2: float, fc: float, mq: float, ts: float, td: float, cpu: float, abends: List[str]) -> str:
    if abends:
        if any(a in {"ASRA", "ASRB"} for a in abends):
            return "program check / storage / data"
        if any(a in {"AEY9"} for a in abends):
            return "security authorization"
        if any(a in {"APCT"} for a in abends):
            return "program definition / loader"
    waits = {
        "db2 wait": db2,
        "file control wait": fc,
        "mq wait": mq,
        "temporary storage wait": ts,
        "transient data wait": td,
        "cpu-bound": cpu,
    }
    name, value = max(waits.items(), key=lambda kv: kv[1])
    return name if value > 0 else "unknown"


def summarize_programs(events: List[Event], issues: List[Issue], config: AnalyzerConfig) -> List[ProgramSummary]:
    by: Dict[str, List[Event]] = {}
    for e in events:
        if e.program:
            by.setdefault(e.program, []).append(e)
    result: List[ProgramSummary] = []
    for program, rows in by.items():
        response_values = [e.response_ms for e in rows if e.response_ms is not None]
        issue_count = len([i for i in issues if i.program == program])
        abend_count = len([e for e in rows if e.abend_code])
        risk = min(100, issue_count * 8 + abend_count * 20 + score_from_ratio(max(response_values or [0.0]), config.critical_response_ms, 25))
        result.append(ProgramSummary(
            program=program,
            transaction_count=len([e for e in rows if e.tranid]),
            tranids=unique_sorted(e.tranid for e in rows),
            regions=unique_sorted(e.region for e in rows),
            abend_count=abend_count,
            max_response_ms=max(response_values or [0.0]),
            avg_response_ms=sum(response_values) / len(response_values) if response_values else 0.0,
            total_cpu_ms=sum(e.cpu_ms or 0 for e in rows),
            total_db2_wait_ms=sum(e.db2_wait_ms or 0 for e in rows),
            total_fc_wait_ms=sum(e.fc_wait_ms or 0 for e in rows),
            risk_score=risk,
        ))
    return sorted(result, key=lambda x: (x.risk_score, x.max_response_ms, x.abend_count), reverse=True)


def summarize_users(events: List[Event], issues: List[Issue], config: AnalyzerConfig) -> List[UserSummary]:
    by: Dict[str, List[Event]] = {}
    for e in events:
        if e.user:
            by.setdefault(e.user, []).append(e)
    result: List[UserSummary] = []
    for user, rows in by.items():
        response_values = [e.response_ms for e in rows if e.response_ms is not None]
        issue_count = len([i for i in issues if any(ev.user == user for ev in rows if ev.line_no == i.line_no)])
        abend_count = len([e for e in rows if e.abend_code])
        risk = min(100, issue_count * 7 + abend_count * 18 + score_from_ratio(max(response_values or [0.0]), config.critical_response_ms, 20))
        result.append(UserSummary(
            user=user,
            transaction_count=len([e for e in rows if e.tranid]),
            tranids=unique_sorted(e.tranid for e in rows),
            terminals=unique_sorted(e.terminal for e in rows),
            regions=unique_sorted(e.region for e in rows),
            abend_count=abend_count,
            max_response_ms=max(response_values or [0.0]),
            avg_response_ms=sum(response_values) / len(response_values) if response_values else 0.0,
            risk_score=risk,
        ))
    return sorted(result, key=lambda x: (x.risk_score, x.max_response_ms), reverse=True)


def summarize_regions(events: List[Event], issues: List[Issue], config: AnalyzerConfig) -> List[RegionSummary]:
    by: Dict[str, List[Event]] = {}
    for e in events:
        if e.region:
            by.setdefault(e.region, []).append(e)
    result: List[RegionSummary] = []
    for region, rows in by.items():
        response_values = [e.response_ms for e in rows if e.response_ms is not None]
        issue_count = len([i for i in issues if i.region == region])
        abend_count = len([e for e in rows if e.abend_code])
        risk = min(100, issue_count * 6 + abend_count * 15 + score_from_ratio(max(response_values or [0.0]), config.critical_response_ms, 25))
        result.append(RegionSummary(
            region=region,
            event_count=len(rows),
            transaction_count=len([e for e in rows if e.event_type == "transaction" or e.response_ms is not None]),
            issue_count=issue_count,
            abend_count=abend_count,
            max_response_ms=max(response_values or [0.0]),
            avg_response_ms=sum(response_values) / len(response_values) if response_values else 0.0,
            families=unique_sorted(e.family for e in rows),
            risk_score=risk,
        ))
    return sorted(result, key=lambda x: (x.risk_score, x.issue_count, x.max_response_ms), reverse=True)


def summarize_resources(events: List[Event], issues: List[Issue], config: AnalyzerConfig) -> List[ResourceSummary]:
    by: Dict[Tuple[str, str], List[Event]] = {}
    for e in events:
        candidates = []
        if e.file_name or (e.family == "file_control" and e.resource):
            candidates.append((e.file_name or e.resource, "file"))
        if e.queue_name or e.family == "mq":
            candidates.append((e.queue_name or e.resource, "queue"))
        if e.db2_package or e.db2_plan or e.family == "db2":
            candidates.append((e.db2_package or e.db2_plan or e.resource or "DB2", "db2"))
        if e.resource and not candidates:
            candidates.append((e.resource, "resource"))
        for key in candidates:
            if key[0]:
                by.setdefault(key, []).append(e)
    result: List[ResourceSummary] = []
    for (resource, rtype), rows in by.items():
        waits = []
        for e in rows:
            waits.extend([v for v in [e.db2_wait_ms, e.fc_wait_ms, e.mq_wait_ms, e.ts_wait_ms, e.td_wait_ms] if v is not None])
        issue_count = len([i for i in issues if i.resource == resource or resource in (i.resource or "")])
        risk = min(100, issue_count * 10 + score_from_ratio(max(waits or [0.0]), config.critical_response_ms, 35))
        result.append(ResourceSummary(
            resource=resource,
            resource_type=rtype,
            event_count=len(rows),
            transaction_count=len([e for e in rows if e.tranid]),
            issue_count=issue_count,
            max_wait_ms=max(waits or [0.0]),
            total_wait_ms=sum(waits),
            tranids=unique_sorted(e.tranid for e in rows),
            programs=unique_sorted(e.program for e in rows),
            risk_score=risk,
        ))
    return sorted(result, key=lambda x: (x.risk_score, x.max_wait_ms, x.issue_count), reverse=True)


def create_clusters(events: List[Event], issues: List[Issue], config: AnalyzerConfig) -> List[Cluster]:
    timed = [(dt_from_iso(e.timestamp_iso), e) for e in events if dt_from_iso(e.timestamp_iso)]
    timed.sort(key=lambda x: x[0])
    if not timed:
        return []
    clusters: List[List[Event]] = []
    current: List[Event] = []
    last_dt: Optional[datetime] = None
    for dt, event in timed:
        if last_dt is None or (dt - last_dt).total_seconds() <= config.cluster_window_seconds:
            current.append(event)
        else:
            clusters.append(current)
            current = [event]
        last_dt = dt
    if current:
        clusters.append(current)
    output: List[Cluster] = []
    issue_by_line = {i.line_no: i for i in issues}
    for idx, rows in enumerate(clusters, start=1):
        dts = [dt_from_iso(e.timestamp_iso) for e in rows if dt_from_iso(e.timestamp_iso)]
        start = min(dts) if dts else None
        end = max(dts) if dts else None
        cluster_issues = [issue_by_line[e.line_no] for e in rows if e.line_no in issue_by_line]
        highest = "INFO"
        for e in rows:
            highest = max_sev(highest, e.severity)
        for i in cluster_issues:
            highest = max_sev(highest, i.severity)
        hint = infer_cluster_hint(rows, cluster_issues)
        risk = min(100, len(cluster_issues) * 10 + sum(1 for e in rows if e.abend_code) * 25 + SEVERITY_ORDER.get(highest, 0) * 15)
        output.append(Cluster(
            cluster_id=f"CL{idx:04d}",
            start_time=start.isoformat() if start else "",
            end_time=end.isoformat() if end else "",
            duration_seconds=(end - start).total_seconds() if start and end else 0.0,
            event_count=len(rows),
            issue_count=len(cluster_issues),
            highest_severity=highest,
            regions=unique_sorted(e.region for e in rows),
            tranids=unique_sorted(e.tranid for e in rows),
            programs=unique_sorted(e.program for e in rows),
            families=unique_sorted(e.family for e in rows),
            root_cause_hint=hint,
            risk_score=risk,
        ))
    return sorted(output, key=lambda x: (x.risk_score, x.issue_count, x.event_count), reverse=True)


def infer_cluster_hint(events: List[Event], issues: List[Issue]) -> str:
    texts = " ".join([e.raw.upper() for e in events] + [i.code.upper() for i in issues])
    if "SECURITY" in texts or "AEY9" in texts or "AUTHORIZATION" in texts:
        return "Security / authorization failure candidate"
    if "DB2" in texts or "SQLCODE" in texts or "CICS_DB2_WAIT_HIGH" in texts:
        return "DB2 wait / SQL / lock or package candidate"
    if "DFHFC" in texts or "FCWAIT" in texts or "CICS_FILE_CONTROL_WAIT_HIGH" in texts:
        return "File control / VSAM / I/O contention candidate"
    if "ASRA" in texts or "STORAGE VIOLATION" in texts:
        return "Program check / storage violation candidate"
    if "MQ" in texts:
        return "MQ wait or queue dependency candidate"
    if "ABEND" in texts:
        return "Transaction abend cluster"
    return "General CICS incident cluster"


def summarize_hourly(events: List[Event]) -> List[HourBucket]:
    buckets: Dict[str, List[Event]] = {}
    for e in events:
        dt = dt_from_iso(e.timestamp_iso)
        if not dt:
            continue
        key = dt.strftime("%Y-%m-%d %H:00") if dt.year != 1900 else dt.strftime("%H:00")
        buckets.setdefault(key, []).append(e)
    result: List[HourBucket] = []
    for hour, rows in sorted(buckets.items()):
        response_values = [e.response_ms for e in rows if e.response_ms is not None]
        result.append(HourBucket(
            hour=hour,
            event_count=len(rows),
            transaction_count=len([e for e in rows if e.tranid]),
            issue_count=len([e for e in rows if e.severity in {"WARNING", "ERROR", "CRITICAL"}]),
            abend_count=len([e for e in rows if e.abend_code]),
            max_response_ms=max(response_values or [0.0]),
            avg_response_ms=sum(response_values) / len(response_values) if response_values else 0.0,
        ))
    return result


def compare_with_baseline(current: List[TransactionSummary], baseline: List[TransactionSummary]) -> List[CompareRow]:
    base_by = {t.tranid: t for t in baseline}
    rows: List[CompareRow] = []
    for curr in current:
        base = base_by.get(curr.tranid)
        if not base:
            rows.append(CompareRow("transaction", curr.tranid, "new_transaction", 0.0, 1.0, 1.0, 100.0, "WARNING"))
            continue
        for metric in ["avg_response_ms", "max_response_ms", "total_db2_wait_ms", "total_fc_wait_ms", "abend_count"]:
            b = float(getattr(base, metric, 0.0) or 0.0)
            c = float(getattr(curr, metric, 0.0) or 0.0)
            delta = c - b
            pct = (delta / b * 100.0) if b else (100.0 if c else 0.0)
            severity = "INFO"
            if metric == "abend_count" and c > b:
                severity = "CRITICAL"
            elif pct >= 200:
                severity = "CRITICAL"
            elif pct >= 100:
                severity = "ERROR"
            elif pct >= 50:
                severity = "WARNING"
            rows.append(CompareRow("transaction", curr.tranid, metric, b, c, delta, pct, severity))
    return sorted(rows, key=lambda r: (SEVERITY_ORDER.get(r.severity, 0), abs(r.delta_pct)), reverse=True)


def compute_status_and_score(issues: List[Issue], events: List[Event], transactions: List[TransactionSummary], clusters: List[Cluster]) -> Tuple[str, str, int]:
    highest = "INFO"
    for issue in issues:
        highest = max_sev(highest, issue.severity)
    abend_count = len([e for e in events if e.abend_code])
    critical = len([i for i in issues if i.severity == "CRITICAL"])
    errors = len([i for i in issues if i.severity == "ERROR"])
    warnings = len([i for i in issues if i.severity == "WARNING"])
    max_tran_risk = max([t.risk_score for t in transactions], default=0)
    max_cluster_risk = max([c.risk_score for c in clusters], default=0)
    score = min(100, critical * 20 + errors * 12 + warnings * 5 + abend_count * 10 + max_tran_risk // 2 + max_cluster_risk // 3)
    if critical or abend_count >= 3 or score >= 90:
        status = "FAILED" if critical or abend_count else "HIGH_RISK"
    elif errors or score >= 60:
        status = "DEGRADED"
    elif warnings or score >= 25:
        status = "ATTENTION"
    else:
        status = "OK"
    return status, highest, score


def redact_text(text: str) -> str:
    text = re.sub(r"\b[A-Z0-9$#@]{1,8}\.[A-Z0-9$#@.\-]{2,}\b", "<DATASET>", text)
    text = re.sub(r"\bUSR[0-9A-Z]{2,}\b", "<USER>", text)
    text = re.sub(r"\bTRM[0-9A-Z]{1,}\b", "<TERM>", text)
    return text


def apply_redaction(result: AnalysisResult) -> AnalysisResult:
    for e in result.events:
        e.raw = redact_text(e.raw)
        e.user = "<USER>" if e.user else e.user
        e.terminal = "<TERM>" if e.terminal else e.terminal
        if e.resource:
            e.resource = redact_text(e.resource)
        if e.file_name:
            e.file_name = redact_text(e.file_name)
    for i in result.issues:
        i.evidence = [redact_text(x) for x in i.evidence]
        i.resource = redact_text(i.resource) if i.resource else i.resource
    for t in result.transactions:
        t.users = ["<USER>" for _ in t.users]
        t.terminals = ["<TERM>" for _ in t.terminals]
        t.files = [redact_text(x) for x in t.files]
    return result


def analyze_events(events: List[Event], source_name: str, line_count: int, config: AnalyzerConfig, baseline_events: Optional[List[Event]] = None) -> AnalysisResult:
    issues = derive_event_issues(events, config)
    transactions = summarize_transactions(events, issues, config)
    programs = summarize_programs(events, issues, config)
    users = summarize_users(events, issues, config)
    regions = summarize_regions(events, issues, config)
    resources = summarize_resources(events, issues, config)
    clusters = create_clusters(events, issues, config)
    hourly = summarize_hourly(events)
    compare: List[CompareRow] = []
    if baseline_events:
        baseline_issues = derive_event_issues(baseline_events, config)
        baseline_transactions = summarize_transactions(baseline_events, baseline_issues, config)
        compare = compare_with_baseline(transactions, baseline_transactions)
    status, highest, risk_score = compute_status_and_score(issues, events, transactions, clusters)
    result = AnalysisResult(
        version=VERSION,
        source_name=source_name,
        profile=config.profile,
        status=status,
        highest_severity=highest,
        risk_score=risk_score,
        line_count=line_count,
        event_count=len(events),
        issue_count=len(issues),
        transaction_count=len(transactions),
        program_count=len(programs),
        user_count=len(users),
        region_count=len(regions),
        resource_count=len(resources),
        cluster_count=len(clusters),
        abend_count=len([e for e in events if e.abend_code]),
        events=events,
        issues=issues,
        transactions=transactions,
        programs=programs,
        users=users,
        regions=regions,
        resources=resources,
        clusters=clusters,
        hourly=hourly,
        compare=compare,
    )
    if config.redact:
        return apply_redaction(result)
    return result


def result_to_dict(result: AnalysisResult) -> Dict[str, Any]:
    return asdict(result)


def write_json(result: AnalysisResult, path: str) -> None:
    Path(path).write_text(json.dumps(result_to_dict(result), indent=2, ensure_ascii=False), encoding="utf-8")


def write_csv(path: str, rows: List[Any]) -> None:
    if not path:
        return
    path_obj = Path(path)
    path_obj.parent.mkdir(parents=True, exist_ok=True)
    dict_rows = [asdict(r) if hasattr(r, "__dataclass_fields__") else dict(r) for r in rows]
    if not dict_rows:
        path_obj.write_text("", encoding="utf-8")
        return
    keys = list(dict_rows[0].keys())
    with path_obj.open("w", encoding="utf-8", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=keys)
        writer.writeheader()
        for row in dict_rows:
            clean = {}
            for key in keys:
                value = row.get(key)
                if isinstance(value, list):
                    clean[key] = "; ".join(str(v) for v in value)
                else:
                    clean[key] = value
            writer.writerow(clean)


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


def render_table(title: str, rows: List[Any], columns: List[Tuple[str, str]], limit: int = 20) -> str:
    body = []
    for row in rows[:limit]:
        data = asdict(row) if hasattr(row, "__dataclass_fields__") else row
        cells = "".join(f"<td>{html_escape(data.get(key, ''))}</td>" for key, _ in columns)
        body.append(f"<tr>{cells}</tr>")
    headers = "".join(f"<th>{html_escape(label)}</th>" for _, label in columns)
    return f"""
    <h2>{html_escape(title)}</h2>
    <table>
      <thead><tr>{headers}</tr></thead>
      <tbody>{''.join(body)}</tbody>
    </table>
    """


def write_html(result: AnalysisResult, path: str) -> None:
    severity_class = result.highest_severity.lower()
    html_text = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CICS Transaction Log Analyzer V2 - {html_escape(result.source_name)}</title>
<style>
body {{ font-family: Segoe UI, Arial, sans-serif; background:#f6f8fb; color:#172033; margin:0; }}
.header {{ background:#102c3d; color:white; padding:28px 36px; border-bottom:5px solid #00b4d8; }}
.header h1 {{ margin:0 0 8px; font-size:26px; }}
.header p {{ margin:0; color:#cfeaf2; }}
.wrap {{ max-width:1180px; margin:28px auto; padding:0 20px; }}
.kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:14px; }}
.card {{ background:white; border:1px solid #dce3ea; border-radius:10px; padding:15px; box-shadow:0 2px 10px rgba(0,0,0,.04); }}
.kpi-value {{ font-size:24px; font-weight:800; color:#0077b6; }}
.kpi-label {{ color:#667085; font-size:13px; margin-top:4px; }}
.badge {{ display:inline-block; border-radius:999px; padding:4px 10px; font-weight:800; font-size:12px; }}
.critical {{ background:#ffebee; color:#b71c1c; }}
.error {{ background:#fff3e0; color:#bf5b00; }}
.warning {{ background:#fff8e1; color:#8a6d00; }}
.info {{ background:#e3f2fd; color:#0d47a1; }}
table {{ width:100%; border-collapse:collapse; background:white; margin:14px 0 26px; border:1px solid #dce3ea; }}
th {{ background:#2c3e50; color:white; text-align:left; padding:9px 10px; font-size:13px; }}
td {{ border-top:1px solid #dce3ea; padding:8px 10px; vertical-align:top; font-size:13px; }}
tr:nth-child(even) td {{ background:#f8fafc; }}
h2 {{ border-left:5px solid #00b4d8; padding-left:12px; margin-top:30px; color:#2c3e50; }}
pre {{ white-space:pre-wrap; background:#282c34; color:#d6deeb; padding:12px; border-radius:8px; overflow:auto; }}
@media(max-width:900px) {{ .kpis {{ grid-template-columns:1fr 1fr; }} }}
</style>
</head>
<body>
<div class="header">
  <h1>CICS Transaction Log Analyzer V2</h1>
  <p>Source: {html_escape(result.source_name)} · Profile: {html_escape(result.profile)} · Version: {VERSION}</p>
</div>
<div class="wrap">
  <div class="kpis">
    <div class="card"><div class="kpi-value">{html_escape(result.status)}</div><div class="kpi-label">Status</div></div>
    <div class="card"><div class="kpi-value"><span class="badge {severity_class}">{html_escape(result.highest_severity)}</span></div><div class="kpi-label">Highest severity</div></div>
    <div class="card"><div class="kpi-value">{result.risk_score}/100</div><div class="kpi-label">Risk score</div></div>
    <div class="card"><div class="kpi-value">{result.issue_count}</div><div class="kpi-label">Issues</div></div>
    <div class="card"><div class="kpi-value">{result.transaction_count}</div><div class="kpi-label">Transactions</div></div>
    <div class="card"><div class="kpi-value">{result.program_count}</div><div class="kpi-label">Programs</div></div>
    <div class="card"><div class="kpi-value">{result.cluster_count}</div><div class="kpi-label">Clusters</div></div>
    <div class="card"><div class="kpi-value">{result.abend_count}</div><div class="kpi-label">ABEND events</div></div>
  </div>
"""
    html_text += render_table("Top Transactions", result.transactions, [
        ("tranid", "TRANID"), ("count", "Count"), ("abend_count", "ABENDs"), ("max_response_ms", "Max Resp ms"),
        ("p95_response_ms", "P95 Resp ms"), ("total_db2_wait_ms", "DB2 wait"), ("total_fc_wait_ms", "FC wait"),
        ("likely_area", "Likely area"), ("risk_score", "Risk")
    ], result.transaction_count)
    html_text += render_table("Top Issues", result.issues, [
        ("severity", "Severity"), ("code", "Code"), ("title", "Title"), ("tranid", "Tran"), ("program", "Program"),
        ("metric_name", "Metric"), ("metric_value", "Value"), ("recommendation", "Recommendation")
    ], 50)
    html_text += render_table("Incident Clusters", result.clusters, [
        ("cluster_id", "Cluster"), ("start_time", "Start"), ("end_time", "End"), ("event_count", "Events"),
        ("issue_count", "Issues"), ("highest_severity", "Severity"), ("root_cause_hint", "Root-cause hint"), ("risk_score", "Risk")
    ], 30)
    html_text += render_table("Programs", result.programs, [
        ("program", "Program"), ("transaction_count", "Tx Count"), ("abend_count", "ABENDs"), ("max_response_ms", "Max Resp"),
        ("total_db2_wait_ms", "DB2 wait"), ("total_fc_wait_ms", "FC wait"), ("risk_score", "Risk")
    ], 30)
    html_text += render_table("Resources", result.resources, [
        ("resource", "Resource"), ("resource_type", "Type"), ("event_count", "Events"), ("issue_count", "Issues"),
        ("max_wait_ms", "Max wait"), ("total_wait_ms", "Total wait"), ("risk_score", "Risk")
    ], 30)
    html_text += render_table("Baseline Compare", result.compare, [
        ("entity_name", "Entity"), ("metric", "Metric"), ("baseline_value", "Baseline"), ("current_value", "Current"),
        ("delta", "Delta"), ("delta_pct", "Delta %"), ("severity", "Severity")
    ], 50) if result.compare else ""
    html_text += render_table("Timeline Events", result.events, [
        ("timestamp", "Time"), ("region", "Region"), ("tranid", "Tran"), ("task", "Task"), ("program", "Program"),
        ("message_id", "Message"), ("family", "Family"), ("severity", "Severity"), ("abend_code", "ABEND"), ("raw", "Raw")
    ], 80)
    html_text += "</div></body></html>"
    Path(path).write_text(html_text, encoding="utf-8")


def print_summary(result: AnalysisResult) -> None:
    print("CICS Transaction Log Analyzer V2")
    print(f"Status           : {result.status}")
    print(f"Highest severity : {result.highest_severity}")
    print(f"Risk score       : {result.risk_score}/100")
    print(f"Profile          : {result.profile}")
    print(f"Lines            : {result.line_count}")
    print(f"Events           : {result.event_count}")
    print(f"Issues           : {result.issue_count}")
    print(f"Transactions     : {result.transaction_count}")
    print(f"Programs         : {result.program_count}")
    print(f"Users            : {result.user_count}")
    print(f"Regions          : {result.region_count}")
    print(f"Resources        : {result.resource_count}")
    print(f"Clusters         : {result.cluster_count}")
    print(f"ABENDs           : {result.abend_count}")
    print()
    print("TOP TRANSACTIONS")
    for t in result.transactions[:10]:
        print(f"{t.tranid:8} count={t.count:<3} abends={t.abend_count:<2} max_resp={t.max_response_ms:<8.1f} p95={t.p95_response_ms:<8.1f} area={t.likely_area} risk={t.risk_score}")
    print()
    print("TOP ISSUES")
    for i in result.issues[:10]:
        print(f"[{i.severity}] {i.code} {i.tranid} {i.program} {i.title}")


def run_analysis(args: argparse.Namespace) -> AnalysisResult:
    config = build_config(args)
    baseline_events: Optional[List[Event]] = None
    if args.compare_with:
        baseline_events, _ = parse_input_path(Path(args.compare_with), config)
    if args.demo:
        demo = DEMO_LOGS.get(args.demo, DEMO_LOGS["mixed"])
        events = parse_input_text(demo, config)
        return analyze_events(events, f"demo:{args.demo}", len(demo.splitlines()), config, baseline_events)
    if not args.input_file:
        raise SystemExit("Missing input_file or --demo")
    events, line_count = parse_input_path(Path(args.input_file), config)
    return analyze_events(events, args.input_file, line_count, config, baseline_events)


def write_sample_files(target_dir: str) -> None:
    base = Path(target_dir)
    base.mkdir(parents=True, exist_ok=True)
    for name, content in DEMO_LOGS.items():
        (base / f"sample_cics_v2_{name}.log").write_text(content, encoding="utf-8")
    (base / "sample_cics_v2_baseline.log").write_text(BASELINE_LOG, encoding="utf-8")
    policy = {
        "critical_transactions": ["PAY1", "ADM1"],
        "critical_programs": ["PAYMAIN", "ADMTOOL"],
        "sensitive_resources": ["PAY", "PAYROLL", "PAY.ADMIN", "CUSTOMER", "CARD"],
        "banned_transactions": ["ZZZZ"],
        "transaction_slo_ms": {"PAY1": 2500, "PAY2": 3000, "ADM1": 1000},
        "thresholds": {"warn_response_ms": 800, "critical_response_ms": 7000},
    }
    (base / "sample_cics_v2_policy.json").write_text(json.dumps(policy, indent=2), encoding="utf-8")
    csv_path = base / "sample_cics_v2_activity.csv"
    rows = [
        {"timestamp":"2026-05-22 08:53:38","region":"CICSPROD","tranid":"PAY1","task":"000124","userid":"USR001","term":"TRM1","program":"PAYMAIN","response_ms":"2850","cpu_ms":"230","db2_wait_ms":"1900","fc_wait_ms":"120","status":"OK"},
        {"timestamp":"2026-05-22 08:53:40","region":"CICSPROD","tranid":"PAY1","task":"000125","userid":"USR002","term":"TRM2","program":"PAYMAIN","response_ms":"14200","cpu_ms":"390","db2_wait_ms":"12100","fc_wait_ms":"250","status":"OK"},
        {"timestamp":"2026-05-22 08:53:45","region":"CICSPROD","tranid":"PAY2","task":"000126","userid":"USR003","term":"TRM3","program":"PAYFILE","response_ms":"6200","cpu_ms":"180","db2_wait_ms":"50","fc_wait_ms":"5400","status":"OK","file":"PAYMSTR"},
        {"timestamp":"2026-05-22 08:53:47","region":"CICSPROD","tranid":"PAY3","task":"000127","userid":"USR004","term":"TRM4","program":"PAYBAD","response_ms":"600","cpu_ms":"410","db2_wait_ms":"20","fc_wait_ms":"30","abend":"ASRA","status":"ABEND"},
        {"timestamp":"2026-05-22 08:54:10","region":"CICSPROD","tranid":"PAY4","task":"000130","userid":"USR006","term":"TRM6","program":"PAYSEC","response_ms":"300","cpu_ms":"60","db2_wait_ms":"10","fc_wait_ms":"20","abend":"AEY9","status":"ABEND","resource":"PAY.ADMIN"},
    ]
    fieldnames = sorted({key for row in rows for key in row.keys()})
    with csv_path.open("w", encoding="utf-8", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)


def should_fail(result: AnalysisResult, fail_on: str) -> bool:
    if not fail_on:
        return False
    target = fail_on.upper()
    if target not in SEVERITY_ORDER:
        return False
    return SEVERITY_ORDER.get(result.highest_severity, 0) >= SEVERITY_ORDER[target]


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="CICS Transaction Log Analyzer V2")
    parser.add_argument("input_file", nargs="?", help="CICS log, monitoring CSV, or text export")
    parser.add_argument("--demo", choices=sorted(DEMO_LOGS.keys()), help="Run an embedded demo scenario")
    parser.add_argument("--profile", default="production", choices=sorted(PROFILE_DEFAULTS.keys()), help="Analysis profile")
    parser.add_argument("--custom-policy", help="Optional JSON policy file")
    parser.add_argument("--compare-with", help="Baseline file to compare against")
    parser.add_argument("--write-samples", help="Write sample input files to this directory")
    parser.add_argument("--redact", action="store_true", help="Redact sensitive values in outputs")
    parser.add_argument("--fail-on", choices=["INFO", "WARNING", "ERROR", "CRITICAL"], help="Exit non-zero when highest severity reaches this value")
    parser.add_argument("--json", dest="json_output", help="Write JSON analysis")
    parser.add_argument("--html", dest="html_output", help="Write HTML report")
    parser.add_argument("--csv-events", help="Write normalized events CSV")
    parser.add_argument("--csv-transactions", help="Write transaction summary CSV")
    parser.add_argument("--csv-programs", help="Write program summary CSV")
    parser.add_argument("--csv-users", help="Write user summary CSV")
    parser.add_argument("--csv-regions", help="Write region summary CSV")
    parser.add_argument("--csv-resources", help="Write resource summary CSV")
    parser.add_argument("--csv-issues", help="Write issues CSV")
    parser.add_argument("--csv-clusters", help="Write incident clusters CSV")
    parser.add_argument("--csv-hourly", help="Write hourly bucket CSV")
    parser.add_argument("--csv-compare", help="Write baseline comparison CSV")
    parser.add_argument("--warn-response-ms", type=float)
    parser.add_argument("--error-response-ms", type=float)
    parser.add_argument("--critical-response-ms", type=float)
    parser.add_argument("--warn-db2-wait-ms", type=float)
    parser.add_argument("--critical-db2-wait-ms", type=float)
    parser.add_argument("--warn-fc-wait-ms", type=float)
    parser.add_argument("--critical-fc-wait-ms", type=float)
    parser.add_argument("--warn-cpu-ms", type=float)
    parser.add_argument("--critical-cpu-ms", type=float)
    parser.add_argument("--cluster-window-seconds", type=int)
    parser.add_argument("--context-lines", type=int)
    parser.add_argument("--top-limit", type=int)
    return parser


def main(argv: Optional[List[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.write_samples:
        write_sample_files(args.write_samples)
        print(f"Sample files written to: {args.write_samples}")
        return 0
    result = run_analysis(args)
    print_summary(result)
    if args.json_output:
        write_json(result, args.json_output)
        print(f"JSON exported to: {args.json_output}")
    if args.html_output:
        write_html(result, args.html_output)
        print(f"HTML exported to: {args.html_output}")
    write_csv(args.csv_events, result.events)
    write_csv(args.csv_transactions, result.transactions)
    write_csv(args.csv_programs, result.programs)
    write_csv(args.csv_users, result.users)
    write_csv(args.csv_regions, result.regions)
    write_csv(args.csv_resources, result.resources)
    write_csv(args.csv_issues, result.issues)
    write_csv(args.csv_clusters, result.clusters)
    write_csv(args.csv_hourly, result.hourly)
    write_csv(args.csv_compare, result.compare)
    if should_fail(result, args.fail_on or ""):
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
