# Signal Demo MVP — CSV Generator Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a parameterized CSV generator system that produces PA-market (25 files) and NJ-market (25 files) test sets with state-change patients, transferred patients, and all normalizer challenge types. Also deliver the Bug Hunter report.

**Architecture:** `generate_supplier_set.py` is the core reusable generator. It reads `market_data.json` for state-specific payer names and geography. State-specific wrappers (`generate_pa_set.py`, `generate_nj_set.py`) call it with preset args. The Bug Hunter is a script that runs all 50 CSVs through the Signal normalizer and returns a structured failure report.

**Tech Stack:** Python 3.11 (stdlib only — no external dependencies). Signal normalizer lives at `python-backend/api/normalizer.py`.

**Key paths:**
- Reference generator: `test-data/generate_samples.py` — read this before writing anything
- Output: `test-data/pa-set/` (25 files) and `test-data/nj-set/` (25 files)
- Normalizer import path (from test-data dir): `sys.path.insert(0, '../python-backend')`

**Before starting:** Read `test-data/generate_samples.py` in full. The new generators follow the same HEADER_VARIANTS pattern and DATE_BUCKETS approach, extended for PA/NJ payers, doc fields, and state-change/transfer scenarios.

---

## File Map

**Create:**
- `test-data/market_data.json` — state-to-payer-names config
- `test-data/generate_supplier_set.py` — parameterized generator (accepts --state, --supplier, --patients, --challenge-level)
- `test-data/generate_pa_set.py` — PA market wrapper
- `test-data/generate_nj_set.py` — NJ market wrapper
- `test-data/bug_hunter.py` — runs all 50 CSVs through normalizer, outputs failure report
- `test-data/pa-set/` — 25 generated CSV files
- `test-data/nj-set/` — 25 generated CSV files

---

## Task 1: market_data.json

- [ ] **Step 1: Create market_data.json**

Create `test-data/market_data.json`:
```json
{
  "PA": {
    "supplier_name": "Gaboro DME",
    "branch_prefix": "PA",
    "branch_id": "PA-001",
    "state": "PA",
    "payers": {
      "medicare_ffs": ["Medicare Part B", "Medicare", "CMS"],
      "medicare_advantage": ["UPMC Health Plan", "Highmark BlueCross BlueShield", "Aetna PA Medicare"],
      "medicaid": ["PA Medicaid", "Keystone First", "AmeriHealth Caritas PA", "UPMC for You"],
      "commercial": ["Independence Blue Cross", "Highmark BCBS PA", "Aetna PA", "Cigna PA", "Geisinger"]
    },
    "geographies": ["Philadelphia", "Bucks County", "Montgomery County", "Chester County"],
    "prescribers": ["Penn Medicine", "Jefferson Health", "Temple Health", "Drexel Medicine"]
  },
  "NJ": {
    "supplier_name": "Gaboro DME",
    "branch_prefix": "NJ",
    "branch_id": "NJ-002",
    "state": "NJ",
    "payers": {
      "medicare_ffs": ["Medicare Part B", "Medicare", "Medicare FFS NJ"],
      "medicare_advantage": ["Aetna NJ Medicare", "UnitedHealthcare NJ Medicare", "Horizon BCBS NJ Medicare"],
      "medicaid": ["NJ FamilyCare", "WellCare NJ", "AmeriHealth NJ"],
      "commercial": ["Horizon BCBS NJ", "Aetna NJ", "Cigna NJ", "Oxford Health", "UnitedHealthcare NJ"]
    },
    "geographies": ["Bergen County", "Essex County", "Hudson County", "Passaic County"],
    "prescribers": ["Hackensack Meridian", "RWJBarnabas", "Atlantic Health", "Valley Health"]
  }
}
```

- [ ] **Step 2: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add test-data/market_data.json
git commit -m "feat: market_data.json — PA/NJ payer and geography config"
```

---

## Task 2: generate_supplier_set.py (parameterized core generator)

- [ ] **Step 1: Read the reference generator**

Read `test-data/generate_samples.py` in full before writing. Key patterns to follow:
- HEADER_VARIANTS list structure (tuple of column names + date format + extras dict)
- DATE_BUCKETS for status targeting
- Random seed for reproducibility
- Output naming pattern

- [ ] **Step 2: Create generate_supplier_set.py**

Create `test-data/generate_supplier_set.py`:
```python
"""
generate_supplier_set.py — Parameterized Signal CSV generator.
Produces N files for a given state/supplier/challenge-level combination.

Usage:
    python generate_supplier_set.py --state PA --supplier "Gaboro DME" --patients 30 --challenge-level high --output pa-set
    python generate_supplier_set.py --state NJ --supplier "Gaboro DME" --patients 25 --challenge-level high --output nj-set

Challenge levels:
    low:    canonical headers, clean dates, valid payers
    medium: header variants, mixed date formats, some nulls
    high:   all variants + nulls + malformed dates + transfer patients + state-change patients
"""

import argparse
import csv
import json
import os
import random
from datetime import date, timedelta

random.seed(42)

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
MARKET_DATA_PATH = os.path.join(SCRIPT_DIR, "market_data.json")

DEVICE_TYPES = ["dexcom_g7", "dexcom_g6", "freestyle_libre_3", "freestyle_libre_2"]
COMPONENTS = {
    "dexcom_g7": "sensor", "dexcom_g6": "sensor",
    "freestyle_libre_3": "sensor", "freestyle_libre_2": "sensor",
}

TODAY = date.today()

DATE_BUCKETS = {
    "active":            (TODAY - timedelta(days=10),  TODAY - timedelta(days=1)),
    "renewal_soon":      (TODAY - timedelta(days=270), TODAY - timedelta(days=260)),
    "renewal_elevated":  (TODAY - timedelta(days=300), TODAY - timedelta(days=290)),
    "renewal_critical":  (TODAY - timedelta(days=315), TODAY - timedelta(days=310)),
    "visit_required":    (TODAY - timedelta(days=400), TODAY - timedelta(days=380)),
    "supply_lapsed":     (TODAY - timedelta(days=600), TODAY - timedelta(days=500)),
}

# Header variants — (patient_id_col, device_col, date_col, qty_col, payer_col, component_col, date_fmt, extras_factory)
# extras_factory(state_data) → dict of extra columns to include
BASE_HEADER_VARIANTS = [
    # 1 — canonical
    ("patient_id", "device_type", "shipment_date", "quantity", "payer", "component",
     "%Y-%m-%d", lambda sd: {}),
    # 2 — Brightree-style
    ("Patient ID", "Item Description", "Service Date", "Qty", "Insurance Name", "Item Type",
     "%m/%d/%Y", lambda sd: {"Prescriber NPI": "1234567890", "Branch": sd["branch_id"]}),
    # 3 — all caps
    ("PT_ID", "DEVICE", "SHIP DATE", "UNITS", "CARRIER", "TYPE",
     "%Y-%m-%d", lambda sd: {}),
    # 4 — MRN + text date
    ("MRN", "Product Name", "Dispense Date", "Qty Dispensed", "Plan Name", "Supply Type",
     "%d-%b-%Y", lambda sd: {"Supplier": sd["supplier_name"], "State": sd["state"]}),
    # 5 — account_number
    ("Account Number", "Product", "Fill Date", "Count", "Primary Payer", "component",
     "%m/%d/%y", lambda sd: {}),
    # 6 — external ref + ISO datetime
    ("External Patient Ref", "Item", "Date of Service", "Qty Shipped", "Insurance", "item_type",
     "%Y-%m-%dT%H:%M:%S", lambda sd: {"Notes": "batch export", "Region": sd["geographies"][0]}),
    # 7 — Acct # + YYYYMMDD
    ("Acct #", "DME", "Order Date", "quantity", "plan", "component_type",
     "%Y%m%d", lambda sd: {}),
    # 8 — patientid no space + dispense date
    ("patientid", "devicetype", "dispensedate", "qty", "payername", "supplytype",
     "%m/%d/%Y", lambda sd: {}),
    # 9 — pt_id + dos
    ("pt_id", "product_type", "dos", "qty", "ins_name", "component",
     "%Y-%m-%d", lambda sd: {}),
    # 10 — account_no + hcpcs
    ("account_no", "hcpcs_description", "service_date", "units", "primary_payer", "supply_type",
     "%m-%d-%Y", lambda sd: {"HCPCS Code": "A9277"}),
    # 11 — patient_account + commercial
    ("patient_account", "description", "ship_date", "quantity_dispensed", "carrier", "component",
     "%Y-%m-%d", lambda sd: {"Account Manager": "Staff"}),
    # 12 — id + product
    ("id", "product", "fill_date", "qty_shipped", "payer", "item_type",
     "%m/%d/%Y", lambda sd: {}),
    # 13 — PT ID spaces
    ("PT ID", "Device Type", "Shipment Date", "Quantity", "Insurance", "Component",
     "%Y-%m-%d", lambda sd: {"Region": sd["geographies"][1] if len(sd["geographies"]) > 1 else ""}),
    # 14 — lowercase no space
    ("patientid", "devicetype", "dispensedate", "qty", "payername", "supplytype",
     "%m/%d/%Y", lambda sd: {}),
    # 15 — account_number + d/m/y
    ("account_number", "item_description", "order_date", "units_dispensed", "plan_name", "component",
     "%d/%m/%Y", lambda sd: {"Facility": f"{sd['supplier_name']} Main"}),
    # 16 — member_id
    ("member_id", "Product Type", "Service Date", "Qty", "Insurance Name", "Component Type",
     "%Y-%m-%d", lambda sd: {}),
    # 17 — acct no
    ("acct no", "dme description", "last ship date", "qty dispensed", "payer name", "type",
     "%m/%d/%Y", lambda sd: {"Billing Cycle": "Monthly"}),
    # 18 — external_patient_ref
    ("external_patient_ref", "equipment description", "service_date", "count", "primary_payer", "supply_type",
     "%Y-%m-%dT%H:%M:%S", lambda sd: {}),
    # 19 — with doc columns: swo_status, pa_status
    ("patient_id", "device_type", "shipment_date", "quantity", "payer", "component",
     "%Y-%m-%d", lambda sd: {"swo_status": "On File", "pa_status": "Not Required", "diagnosis_on_file": "Yes"}),
    # 20 — with visit_date column
    ("patient_id", "device_type", "shipment_date", "quantity", "payer", "component",
     "%Y-%m-%d", lambda sd: {"visit_date": "", "pecos_verified": "Yes", "swo_status": "On File"}),
    # 21 — PA status column variants
    ("Patient ID", "Item Description", "Service Date", "Qty", "Insurance Name", "Item Type",
     "%m/%d/%Y", lambda sd: {"auth_status": "Approved", "swo_status": "On File"}),
    # 22 — Prior Auth column
    ("account_no", "device_type", "shipment_date", "qty", "insurance", "component",
     "%Y-%m-%d", lambda sd: {"Prior Auth": "Pending", "diagnosis_on_file": "Yes"}),
    # 23 — with notes + auth numbers (noise columns)
    ("patient_id", "device_type", "shipment_date", "quantity", "payer", "component",
     "%Y-%m-%d", lambda sd: {"Notes": "see chart", "Auth Number": "A12345", "Staff": "K.F."}),
    # 24 — missing optional columns (no component, no quantity)
    ("patient_id", "device_type", "shipment_date", None, "payer", None,
     "%Y-%m-%d", lambda sd: {}),
    # 25 — transfer patient column
    ("patient_id", "device_type", "shipment_date", "quantity", "payer", "component",
     "%Y-%m-%d", lambda sd: {"Transfer_From": "Prior Supplier LLC", "swo_status": "Pending"}),
]


def _pick_payer(state_data: dict, payer_type: str) -> str:
    payers = state_data["payers"].get(payer_type, [])
    return random.choice(payers) if payers else "Medicare Part B"


def _format_date(d: date, fmt: str) -> str:
    return d.strftime(fmt)


def _random_date_in_bucket(bucket: tuple[date, date]) -> date:
    start, end = bucket
    days = (end - start).days
    return start + timedelta(days=random.randint(0, days))


def _device_display(device: str, header_style: str) -> str:
    """Map device type to display string matching the header style."""
    display_map = {
        "dexcom_g7": {
            "canonical": "dexcom_g7",
            "display": "Dexcom G7",
            "short": "G7",
        },
        "dexcom_g6": {
            "canonical": "dexcom_g6",
            "display": "Dexcom G6",
            "short": "G6",
        },
        "freestyle_libre_3": {
            "canonical": "freestyle_libre_3",
            "display": "FreeStyle Libre 3",
            "short": "FSL3",
        },
        "freestyle_libre_2": {
            "canonical": "freestyle_libre_2",
            "display": "FreeStyle Libre 2",
            "short": "FSL2",
        },
    }
    dm = display_map.get(device, {"canonical": device, "display": device, "short": device})
    if header_style in ("Item Description", "Product Name", "product", "description", "dme description", "equipment description"):
        return dm["display"]
    if header_style in ("DEVICE", "DME"):
        return dm["short"]
    return dm["canonical"]


def _payer_display(payer_str: str, header_style: str) -> str:
    return payer_str  # payer strings are already in display format from market_data.json


def generate_state_change_patients(state_data: dict, file_index: int, num_files: int) -> list[dict]:
    """
    Generate 6 state-change patients whose status evolves across the file sequence.
    Returns patient records that change based on file_index.
    """
    patients = []
    progress = file_index / max(num_files - 1, 1)  # 0.0 to 1.0

    # Patient A: starts RENEWAL_SOON, confirmed in file 10, ACTIVE by file 20
    if file_index < 10:
        shipment_date = TODAY - timedelta(days=270)
        visit_date = ""  # No confirmed visit yet
        swo_status = "On File"
    elif file_index < 20:
        shipment_date = TODAY - timedelta(days=10)
        visit_date = (TODAY - timedelta(days=5)).strftime("%Y-%m-%d")
        swo_status = "On File"
    else:
        shipment_date = TODAY - timedelta(days=5)
        visit_date = (TODAY - timedelta(days=5)).strftime("%Y-%m-%d")
        swo_status = "On File"

    patients.append({
        "patient_id": "SC-A-001",
        "device_type": "dexcom_g7",
        "shipment_date": shipment_date,
        "quantity": 1,
        "payer": _pick_payer(state_data, "medicare_ffs"),
        "component": "sensor",
        "visit_date": visit_date,
        "swo_status": swo_status,
        "pecos_verified": "Yes",
        "pa_status": "Not Required",
        "diagnosis_on_file": "Yes",
    })

    # Patient B: starts ACTIVE, slides to RENEWAL_SOON by file 15
    base_visit_offset = 170 - int(progress * 90)  # 170 → 80 days ago
    patients.append({
        "patient_id": "SC-B-002",
        "device_type": "freestyle_libre_3",
        "shipment_date": TODAY - timedelta(days=5),
        "quantity": 1,
        "payer": _pick_payer(state_data, "medicaid"),
        "component": "sensor",
        "visit_date": (TODAY - timedelta(days=base_visit_offset)).strftime("%Y-%m-%d"),
        "swo_status": "On File",
        "pecos_verified": "Yes",
        "pa_status": "Approved",
        "diagnosis_on_file": "Yes",
    })

    # Patient C: transfer patient appearing mid-sequence, clears by file 18
    if file_index < 12:
        transfer_from = "Prior Supplier LLC"
        swo_status_c = "Pending"
    else:
        transfer_from = ""
        swo_status_c = "On File"
    patients.append({
        "patient_id": "SC-C-003",
        "device_type": "dexcom_g6",
        "shipment_date": TODAY - timedelta(days=30),
        "quantity": 1,
        "payer": _pick_payer(state_data, "commercial"),
        "component": "sensor",
        "Transfer_From": transfer_from,
        "swo_status": swo_status_c,
        "pa_status": "Pending" if file_index < 12 else "Approved",
        "diagnosis_on_file": "Yes" if file_index >= 12 else "No",
    })

    # Patient D: SUPPLY_LAPSED → new shipment after file 8 → ACTIVE
    if file_index < 8:
        shipment_d = TODAY - timedelta(days=600)
    else:
        shipment_d = TODAY - timedelta(days=5)
    patients.append({
        "patient_id": "SC-D-004",
        "device_type": "dexcom_g7",
        "shipment_date": shipment_d,
        "quantity": 1,
        "payer": _pick_payer(state_data, "medicare_advantage"),
        "component": "sensor",
        "swo_status": "On File",
        "pa_status": "Approved" if file_index >= 8 else "Pending",
        "diagnosis_on_file": "Yes",
    })

    # Patients E and F: simple active patients that stay stable (control group)
    patients.append({
        "patient_id": "SC-E-005",
        "device_type": "freestyle_libre_2",
        "shipment_date": TODAY - timedelta(days=3),
        "quantity": 1,
        "payer": _pick_payer(state_data, "medicare_ffs"),
        "component": "sensor",
        "visit_date": (TODAY - timedelta(days=30)).strftime("%Y-%m-%d"),
        "swo_status": "On File",
        "pecos_verified": "Yes",
        "pa_status": "Not Required",
        "diagnosis_on_file": "Yes",
    })
    patients.append({
        "patient_id": "SC-F-006",
        "device_type": "dexcom_g7",
        "shipment_date": TODAY - timedelta(days=7),
        "quantity": 1,
        "payer": _pick_payer(state_data, "commercial"),
        "component": "sensor",
        "swo_status": "On File",
        "pa_status": "Approved",
        "diagnosis_on_file": "Yes",
    })

    return patients


def generate_challenge_rows(state_data: dict, challenge_level: str, file_index: int) -> list[dict]:
    """Generate challenge-type rows based on challenge_level."""
    if challenge_level == "low":
        return []

    challenges = []
    if challenge_level in ("medium", "high"):
        # Null patient_id (should be skipped)
        challenges.append({
            "patient_id": "",
            "device_type": "dexcom_g7",
            "shipment_date": TODAY - timedelta(days=10),
            "quantity": 1,
            "payer": _pick_payer(state_data, "medicare_ffs"),
            "component": "sensor",
        })

    if challenge_level == "high":
        # Future shipment date (edge case)
        challenges.append({
            "patient_id": f"FUTURE-{file_index:03d}",
            "device_type": "dexcom_g7",
            "shipment_date": TODAY + timedelta(days=30),
            "quantity": 1,
            "payer": _pick_payer(state_data, "medicare_ffs"),
            "component": "sensor",
        })
        # Zero quantity
        challenges.append({
            "patient_id": f"ZERQTY-{file_index:03d}",
            "device_type": "freestyle_libre_3",
            "shipment_date": TODAY - timedelta(days=5),
            "quantity": 0,
            "payer": _pick_payer(state_data, "commercial"),
            "component": "sensor",
        })
        # Duplicate patient_id (second one should be caught or processed)
        challenges.append({
            "patient_id": f"DUP-PT-{file_index:03d}",
            "device_type": "dexcom_g7",
            "shipment_date": TODAY - timedelta(days=5),
            "quantity": 1,
            "payer": _pick_payer(state_data, "medicare_ffs"),
            "component": "sensor",
        })
        challenges.append({
            "patient_id": f"DUP-PT-{file_index:03d}",  # duplicate
            "device_type": "freestyle_libre_3",
            "shipment_date": TODAY - timedelta(days=3),
            "quantity": 1,
            "payer": _pick_payer(state_data, "commercial"),
            "component": "sensor",
        })

    return challenges


def generate_patient_batch(
    state_data: dict,
    patient_count: int,
    file_index: int,
    num_files: int,
    challenge_level: str,
) -> list[dict]:
    """Generate the main patient batch for one file."""
    patients = []
    payer_types = ["medicare_ffs", "medicare_ffs", "medicare_advantage", "medicaid", "commercial"]
    status_buckets = [
        "active", "active", "active", "active",
        "renewal_soon", "renewal_elevated", "renewal_critical",
        "visit_required", "supply_lapsed",
    ]

    for i in range(patient_count):
        device = random.choice(DEVICE_TYPES)
        payer_type = random.choice(payer_types)
        payer_str = _pick_payer(state_data, payer_type)
        bucket_name = status_buckets[i % len(status_buckets)]
        shipment_date = _random_date_in_bucket(DATE_BUCKETS[bucket_name])
        qty = random.choice([1, 1, 1, 2, 3])
        geo = random.choice(state_data["geographies"])
        prescriber = random.choice(state_data["prescribers"])

        patient = {
            "patient_id": f"{state_data['branch_prefix']}-{file_index:02d}-{i+1:04d}",
            "device_type": device,
            "shipment_date": shipment_date,
            "quantity": qty,
            "payer": payer_str,
            "component": COMPONENTS.get(device, "sensor"),
        }

        # Add doc fields for PA/NJ scenarios
        if payer_type == "medicare_ffs":
            patient.update({
                "swo_status": random.choice(["On File", "On File", "Pending"]),
                "pecos_verified": random.choice(["Yes", "Yes", "No"]),
                "pa_status": "Not Required",
                "diagnosis_on_file": random.choice(["Yes", "Yes", "No"]),
            })
        elif payer_type == "medicare_advantage":
            patient.update({
                "swo_status": random.choice(["On File", "Pending"]),
                "pecos_verified": random.choice(["Yes", "No"]),
                "pa_status": random.choice(["Approved", "Pending", "Denied"]),
                "diagnosis_on_file": random.choice(["Yes", "No"]),
            })
        else:
            patient.update({
                "swo_status": random.choice(["On File", "Pending", "Expired"]),
                "pa_status": random.choice(["Approved", "Pending", "Not Required"]),
                "diagnosis_on_file": random.choice(["Yes", "No"]),
            })

        # Add visit date for some patients
        if random.random() > 0.4:
            visit_offset = random.randint(30, 200)
            patient["visit_date"] = (TODAY - timedelta(days=visit_offset)).strftime("%Y-%m-%d")
        else:
            patient["visit_date"] = ""

        patients.append(patient)

    return patients


def write_csv_file(
    output_path: str,
    patients: list[dict],
    variant: tuple,
    state_data: dict,
    challenge_level: str,
    file_index: int,
) -> None:
    """Write one CSV file using the given header variant."""
    pid_col, dev_col, date_col, qty_col, payer_col, comp_col, date_fmt, extras_factory = variant
    extras = extras_factory(state_data)

    headers = [h for h in [pid_col, dev_col, date_col, qty_col, payer_col, comp_col] if h is not None]

    # Add doc columns if they appear in patients
    doc_cols = ["visit_date", "swo_status", "pecos_verified", "pa_status", "diagnosis_on_file", "Transfer_From"]
    doc_headers_in_extras = {k for k in extras if k in doc_cols}
    extra_headers = [k for k in extras if k not in doc_cols]

    # Malformed date files (2 per set at indices 7 and 14)
    if challenge_level == "high" and file_index in (7, 14):
        malformed_patients = []
        for p in patients[:3]:
            mp = dict(p)
            mp["_malformed_date"] = True
            malformed_patients.append(mp)
        patients = malformed_patients + patients[3:]

    with open(output_path, "w", newline="") as f:
        all_headers = headers + list(doc_headers_in_extras) + extra_headers

        # Some files: include doc columns explicitly
        if any(col in extras for col in doc_cols):
            for col in doc_cols:
                if col in extras and col not in all_headers:
                    all_headers.append(col)

        writer = csv.DictWriter(f, fieldnames=all_headers, extrasaction="ignore")
        writer.writeheader()

        for p in patients:
            row = {}
            # Map patient dict to column headers
            if pid_col:
                row[pid_col] = p.get("patient_id", "")
            if dev_col:
                row[dev_col] = _device_display(p.get("device_type", "dexcom_g7"), dev_col)
            if date_col:
                sd = p.get("shipment_date", TODAY)
                if isinstance(sd, date):
                    if hasattr(p, "_malformed_date") or p.get("_malformed_date"):
                        row[date_col] = random.choice(["not on file", "pending", "TBD"])
                    else:
                        row[date_col] = _format_date(sd, date_fmt)
                else:
                    row[date_col] = str(sd)
            if qty_col:
                row[qty_col] = p.get("quantity", 1)
            if payer_col:
                row[payer_col] = p.get("payer", "Medicare Part B")
            if comp_col:
                row[comp_col] = p.get("component", "sensor")

            # Doc fields
            for col in doc_cols:
                if col in all_headers:
                    row[col] = p.get(col, "")

            # Extras
            for k, v in extras.items():
                if k in all_headers:
                    row[k] = v

            writer.writerow(row)


def generate_set(
    state: str,
    supplier: str,
    patients: int,
    challenge_level: str,
    output_dir: str,
    num_files: int = 25,
) -> None:
    """Generate num_files CSV files for the given state."""
    with open(MARKET_DATA_PATH) as f:
        market = json.load(f)

    state_data = market.get(state.upper())
    if not state_data:
        raise ValueError(f"State '{state}' not found in market_data.json. Available: {list(market.keys())}")

    state_data["supplier_name"] = supplier  # allow override

    os.makedirs(output_dir, exist_ok=True)

    for i in range(num_files):
        # Rotate through header variants
        variant = BASE_HEADER_VARIANTS[i % len(BASE_HEADER_VARIANTS)]

        # Core patient batch
        patient_rows = generate_patient_batch(state_data, patients, i, num_files, challenge_level)

        # State-change patients (appear in every file)
        sc_patients = generate_state_change_patients(state_data, i, num_files)
        all_patients = sc_patients + patient_rows

        # Challenge rows
        if challenge_level == "high":
            challenge_rows = generate_challenge_rows(state_data, challenge_level, i)
            all_patients = all_patients + challenge_rows

        filename = f"batch-{i+1:02d}-{state.lower()}-{challenge_level}.csv"
        output_path = os.path.join(output_dir, filename)

        write_csv_file(output_path, all_patients, variant, state_data, challenge_level, i)
        print(f"  Written: {filename} ({len(all_patients)} rows)")

    print(f"\nGenerated {num_files} files in {output_dir}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Generate Signal CSV test files")
    parser.add_argument("--state", default="PA")
    parser.add_argument("--supplier", default="Gaboro DME")
    parser.add_argument("--patients", type=int, default=30)
    parser.add_argument("--challenge-level", choices=["low", "medium", "high"], default="high")
    parser.add_argument("--output", default="pa-set")
    parser.add_argument("--files", type=int, default=25)
    args = parser.parse_args()

    output_path = os.path.join(SCRIPT_DIR, args.output)
    generate_set(
        state=args.state,
        supplier=args.supplier,
        patients=args.patients,
        challenge_level=args.challenge_level,
        output_dir=output_path,
        num_files=args.files,
    )
```

- [ ] **Step 3: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add test-data/generate_supplier_set.py
git commit -m "feat: parameterized CSV generator with state-change patients and challenge types"
```

---

## Task 3: State-specific wrappers + generate PA and NJ sets

- [ ] **Step 1: Create generate_pa_set.py**

Create `test-data/generate_pa_set.py`:
```python
"""Generate 25 PA-market CSV files (Philadelphia/Southeastern PA)."""
import os, sys
sys.path.insert(0, os.path.dirname(__file__))
from generate_supplier_set import generate_set

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

if __name__ == "__main__":
    generate_set(
        state="PA",
        supplier="Gaboro DME",
        patients=30,
        challenge_level="high",
        output_dir=os.path.join(SCRIPT_DIR, "pa-set"),
        num_files=25,
    )
    print("PA set complete.")
```

- [ ] **Step 2: Create generate_nj_set.py**

Create `test-data/generate_nj_set.py`:
```python
"""Generate 25 NJ-market CSV files (Northern NJ / Metro NY)."""
import os, sys
sys.path.insert(0, os.path.dirname(__file__))
from generate_supplier_set import generate_set

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

if __name__ == "__main__":
    generate_set(
        state="NJ",
        supplier="Gaboro DME",
        patients=25,
        challenge_level="high",
        output_dir=os.path.join(SCRIPT_DIR, "nj-set"),
        num_files=25,
    )
    print("NJ set complete.")
```

- [ ] **Step 3: Run both generators**

```bash
cd /Users/sttil-solutions/projects/signal/test-data
python generate_pa_set.py
python generate_nj_set.py
```
Expected: 25 files in `test-data/pa-set/` and 25 files in `test-data/nj-set/`.

Verify file count:
```bash
ls test-data/pa-set/ | wc -l && ls test-data/nj-set/ | wc -l
```
Expected: `25` on each line.

- [ ] **Step 4: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add test-data/generate_pa_set.py test-data/generate_nj_set.py test-data/pa-set/ test-data/nj-set/
git commit -m "feat: generate PA and NJ CSV test sets (50 files total)"
```

---

## Task 4: Bug Hunter — run all 50 CSVs through normalizer

- [ ] **Step 1: Create bug_hunter.py**

Create `test-data/bug_hunter.py`:
```python
"""
bug_hunter.py — Run all pa-set and nj-set CSVs through the Signal normalizer.
Reports: filename, error type, line, recommendation.

Usage:
    python test-data/bug_hunter.py > test-data/bug-report.txt
"""

import os
import sys
import json

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(SCRIPT_DIR, "..", "python-backend"))

from api.normalizer import normalize_csv

SETS = [
    os.path.join(SCRIPT_DIR, "pa-set"),
    os.path.join(SCRIPT_DIR, "nj-set"),
]

RESULTS = {
    "total_files": 0,
    "total_records": 0,
    "total_skipped": 0,
    "files_with_errors": [],
    "all_skip_reasons": [],
}


def hunt(directory: str) -> None:
    if not os.path.exists(directory):
        print(f"SKIP: {directory} does not exist")
        return

    files = sorted(f for f in os.listdir(directory) if f.endswith(".csv"))
    for filename in files:
        path = os.path.join(directory, filename)
        RESULTS["total_files"] += 1

        try:
            text = open(path, encoding="utf-8", errors="replace").read()
            records, skipped, mapping_summary = normalize_csv(text)
            RESULTS["total_records"] += len(records)
            RESULTS["total_skipped"] += len(skipped)

            required_missing = mapping_summary.get("required_missing", [])
            file_issues = []

            if required_missing:
                file_issues.append({
                    "type": "REQUIRED_COLUMNS_MISSING",
                    "detail": f"Missing: {required_missing}",
                    "recommendation": "Update HEADER_MAP in normalizer.py to cover these column names",
                })

            for reason in skipped:
                file_issues.append({
                    "type": "ROW_SKIPPED",
                    "detail": reason,
                    "recommendation": "Check date format, device name, or patient_id for this row",
                })
                RESULTS["all_skip_reasons"].append(f"{filename}: {reason}")

            if file_issues:
                RESULTS["files_with_errors"].append({
                    "file": filename,
                    "records_processed": len(records),
                    "issues": file_issues,
                })

        except Exception as e:
            RESULTS["files_with_errors"].append({
                "file": filename,
                "records_processed": 0,
                "issues": [{"type": "EXCEPTION", "detail": str(e), "recommendation": "Fix the exception before pilot"}],
            })

    print(f"Processed directory: {directory} ({len(files)} files)")


if __name__ == "__main__":
    for d in SETS:
        hunt(d)

    print("\n=== BUG HUNTER REPORT ===")
    print(f"Total files: {RESULTS['total_files']}")
    print(f"Total records processed: {RESULTS['total_records']}")
    print(f"Total rows skipped: {RESULTS['total_skipped']}")
    print(f"Files with issues: {len(RESULTS['files_with_errors'])}")

    if RESULTS["files_with_errors"]:
        print("\n--- FILES WITH ISSUES ---")
        for entry in RESULTS["files_with_errors"]:
            print(f"\nFile: {entry['file']} ({entry['records_processed']} records OK)")
            for issue in entry["issues"]:
                print(f"  [{issue['type']}] {issue['detail']}")
                print(f"  Fix: {issue['recommendation']}")
    else:
        print("\nAll files processed successfully. Zero issues found.")

    print("\n=== END REPORT ===")
```

- [ ] **Step 2: Run Bug Hunter**

```bash
cd /Users/sttil-solutions/projects/signal && python test-data/bug_hunter.py 2>&1 | tee test-data/bug-report.txt
```

- [ ] **Step 3: Fix issues found by Bug Hunter**

For each issue in the bug report:
- `REQUIRED_COLUMNS_MISSING`: add the missing column name variants to `HEADER_MAP` in `python-backend/api/normalizer.py`
- `ROW_SKIPPED` due to date format: add the date format to `DATE_FORMATS` list in normalizer.py
- `ROW_SKIPPED` due to device name: add the device alias to `DEVICE_MAP` in normalizer.py
- `EXCEPTION`: fix the root cause in the generator or normalizer

After fixes, re-run Bug Hunter until output shows "Zero issues found" or only expected skips (intentionally malformed rows).

- [ ] **Step 4: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add test-data/bug_hunter.py test-data/bug-report.txt
git commit -m "feat: bug_hunter.py — 50-CSV normalizer stress test; report saved"
```
