<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Core\Database;
use Throwable;

final class VisitsRepository
{
    /** @var string */
    private $hospitalCode;

    /** @var array<string, bool> */
    private $columnCache = [];

    /** @var array<string, bool> */
    private $tableCache = [];

    public function __construct(string $hospitalCode)
    {
        $this->hospitalCode = strtolower($hospitalCode);
    }

    /**
     * Current selected-branch implementation of legacy gmov_s.
     *
     * @return array<int, array<string, mixed>>
     */
    public function monthlyOutpatientVisits(
        int $year,
        int $compareYear,
        ?string $fromDate = null,
        ?string $toDate = null
    ): array {
        return $this->monthlyVisitRows('outpatient', $year, $compareYear, $fromDate, $toDate);
    }

    /**
     * Current selected-branch implementation of legacy perv_s.
     *
     * @return array<int, array<string, mixed>>
     */
    public function monthlyEmergencyVisits(
        int $year,
        int $compareYear,
        ?string $fromDate = null,
        ?string $toDate = null
    ): array {
        return $this->monthlyVisitRows('emergency', $year, $compareYear, $fromDate, $toDate);
    }

    /**
     * Current selected-branch implementation of legacy padm_s.
     *
     * @return array<int, array<string, mixed>>
     */
    public function monthlyAdmissions(
        int $year,
        int $compareYear,
        ?string $fromDate = null,
        ?string $toDate = null
    ): array {
        if ($fromDate !== null && $toDate !== null) {
            $compareFrom = RevenueRepository::shiftDateToYear($fromDate, $compareYear);
            $compareTo = RevenueRepository::shiftDateToYear($toDate, $compareYear);
            $sql = "
                WITH months AS (
                    SELECT code month_id, name_en month_name
                    FROM sec_lookup_detail
                    WHERE parent_id = 1
                ),
                agg AS (
                    SELECT
                        'CURRENT' period_key,
                        TO_NUMBER(TO_CHAR(v.visit_start_date, 'MM')) month_id,
                        COUNT(1) value
                    FROM rcp_entryexit e
                    JOIN rcp_visit v
                      ON v.hosp_id = e.branchid
                     AND v.patientid = e.patientid
                     AND v.visit_id = e.visit_id
                    WHERE v.hosp_id = 1
                      AND NVL(e.baby_normal_bed, 1) = 1
                      AND v.visit_start_date >= TO_DATE(:current_from, 'YYYY-MM-DD')
                      AND v.visit_start_date < TO_DATE(:current_to, 'YYYY-MM-DD') + 1
                    GROUP BY TO_NUMBER(TO_CHAR(v.visit_start_date, 'MM'))

                    UNION ALL

                    SELECT
                        'COMPARE' period_key,
                        TO_NUMBER(TO_CHAR(v.visit_start_date, 'MM')) month_id,
                        COUNT(1) value
                    FROM rcp_entryexit e
                    JOIN rcp_visit v
                      ON v.hosp_id = e.branchid
                     AND v.patientid = e.patientid
                     AND v.visit_id = e.visit_id
                    WHERE v.hosp_id = 1
                      AND NVL(e.baby_normal_bed, 1) = 1
                      AND v.visit_start_date >= TO_DATE(:compare_from, 'YYYY-MM-DD')
                      AND v.visit_start_date < TO_DATE(:compare_to, 'YYYY-MM-DD') + 1
                    GROUP BY TO_NUMBER(TO_CHAR(v.visit_start_date, 'MM'))
                )
                SELECT
                    m.month_id,
                    m.month_name,
                    NVL(MAX(CASE WHEN a.period_key = 'CURRENT' THEN a.value END), 0) current_value,
                    NVL(MAX(CASE WHEN a.period_key = 'COMPARE' THEN a.value END), 0) compare_value
                FROM months m
                LEFT JOIN agg a ON a.month_id = m.month_id
                GROUP BY m.month_id, m.month_name
                ORDER BY m.month_id
            ";

            return $this->normalizeMonthlyRows(Database::fetchAll($this->hospitalCode, $sql, [
                'current_from' => $fromDate,
                'current_to' => $toDate,
                'compare_from' => $compareFrom,
                'compare_to' => $compareTo,
            ]));
        }

        $sql = "
            WITH p AS (
                SELECT :current_year current_year, :compare_year compare_year FROM dual
            ),
            months AS (
                SELECT code month_id, name_en month_name
                FROM sec_lookup_detail
                WHERE parent_id = 1
            ),
            agg AS (
                SELECT
                    EXTRACT(YEAR FROM v.visit_start_date) year_id,
                    TO_NUMBER(TO_CHAR(v.visit_start_date, 'MM')) month_id,
                    COUNT(1) value
                FROM rcp_entryexit e
                JOIN rcp_visit v
                  ON v.hosp_id = e.branchid
                 AND v.patientid = e.patientid
                 AND v.visit_id = e.visit_id
                CROSS JOIN p
                WHERE v.hosp_id = 1
                  AND NVL(e.baby_normal_bed, 1) = 1
                  AND EXTRACT(YEAR FROM v.visit_start_date) IN (p.current_year, p.compare_year)
                GROUP BY
                    EXTRACT(YEAR FROM v.visit_start_date),
                    TO_NUMBER(TO_CHAR(v.visit_start_date, 'MM'))
            )
            SELECT
                m.month_id,
                m.month_name,
                NVL(MAX(CASE WHEN a.year_id = p.current_year THEN a.value END), 0) current_value,
                NVL(MAX(CASE WHEN a.year_id = p.compare_year THEN a.value END), 0) compare_value
            FROM months m
            CROSS JOIN p
            LEFT JOIN agg a ON a.month_id = m.month_id
            GROUP BY m.month_id, m.month_name
            ORDER BY m.month_id
        ";

        return $this->monthlyRows($sql, $year, $compareYear);
    }

    /**
     * Current implementation of legacy vfr. The selected period is summarized
     * as New versus Follow-up activity.
     *
     * @return array<int, array<string, mixed>>
     */
    public function followupMix(int $year, ?string $fromDate = null, ?string $toDate = null): array
    {
        $where = 'EXTRACT(YEAR FROM r.expecteddonedate) = :year';
        $params = ['year' => $year];

        if ($fromDate !== null && $toDate !== null) {
            $where = "r.expecteddonedate >= TO_DATE(:from_date, 'YYYY-MM-DD')\n"
                   . "              AND r.expecteddonedate < TO_DATE(:to_date, 'YYYY-MM-DD') + 1";
            $params = [
                'from_date' => $fromDate,
                'to_date' => $toDate,
            ];
        }

        $sql = "
            SELECT
                CASE WHEN r.res_type = 1 THEN 'followup' ELSE 'new' END category,
                COUNT(1) value
            FROM rcp_service_reservation r
            JOIN rcp_visit v
              ON v.visit_id = r.visit_id
             AND v.patientid = r.patientid
            WHERE r.hosp_id IN (1)
              AND {$where}
              AND r.servstatus NOT IN ('C', 'F')
              AND r.sched_serial IS NOT NULL
            GROUP BY CASE WHEN r.res_type = 1 THEN 'followup' ELSE 'new' END
        ";

        $rows = Database::fetchAll($this->hospitalCode, $sql, $params);
        return $this->normalizeMixRows($rows);
    }

    /**
     * Current implementation of the recovered legacy acty / get_admission_category_type contract.
     *
     * The historical contract groups inpatient admissions (CLASSCODE = 1)
     * by patient category type: Cash, Credit, and Insurance. The selected
     * hospital database keeps the legacy HOSP_ID = 1 contract inside each
     * branch connection.
     *
     * @return array<int, array{category:string,value:float}>
     */
    public function admissionCategoryMix(
        int $year,
        ?string $fromDate = null,
        ?string $toDate = null
    ): array {
        $where = 'EXTRACT(YEAR FROM v.visit_start_date) = :acty_year';
        $params = ['acty_year' => $year];

        if ($fromDate !== null && $toDate !== null) {
            $where = "v.visit_start_date >= TO_DATE(:acty_from_date, 'YYYY-MM-DD')\n"
                   . "              AND v.visit_start_date < TO_DATE(:acty_to_date, 'YYYY-MM-DD') + 1";
            $params = [
                'acty_from_date' => $fromDate,
                'acty_to_date' => $toDate,
            ];
        }

        $sql = "
            SELECT
                CASE
                    WHEN pt.cat_type = 1 THEN 'cash'
                    WHEN pt.cat_type = 2 THEN 'credit'
                    WHEN pt.cat_type = 3 THEN 'insurance'
                    ELSE 'other'
                END category,
                COUNT(1) value
            FROM rcp_visit v
            JOIN reg_comp_category c
              ON v.hosp_id = c.hosp_id
             AND v.financlassid = c.financlassid
             AND v.patcatid = c.patcatid
             AND v.main_patcatid = c.main_patcatid
            LEFT JOIN stp_patcat_type pt
              ON c.pat_cat_type = pt.cat_type_id
            WHERE v.hosp_id = 1
              AND {$where}
              AND v.classcode = 1
            GROUP BY pt.cat_type
        ";

        return $this->normalizeAdmissionCategoryRows(
            Database::fetchAll($this->hospitalCode, $sql, $params)
        );
    }

    /**
     * Current implementation of the recovered legacy
     * out_cash_cr / get_out_patient_cash_credit contract.
     *
     * Historical time scope is TODAY only. The legacy year/date dispatcher
     * parameters were accepted but were not used by the function body.
     *
     * @return array<int, array{category:string,value:float}>
     */
    public function outpatientCashCreditToday(): array
    {
        $sql = "
            SELECT
                CASE
                    WHEN pt.cat_type = 1 THEN 'cash'
                    ELSE 'credit'
                END category,
                COUNT(*) value
            FROM rcp_service_reservation r
            JOIN rcp_visit v
              ON v.visit_id = r.visit_id
             AND v.patientid = r.patientid
            JOIN stp_places p
              ON p.place_id1 = r.place_id
            JOIN reg_comp_category c
              ON v.hosp_id = c.hosp_id
             AND v.patcatid = c.patcatid
             AND v.main_patcatid = c.main_patcatid
             AND TO_NUMBER(v.financlassid) = TO_NUMBER(c.financlassid)
            LEFT JOIN stp_patcat_type pt
              ON pt.cat_type_id = c.pat_cat_type
            WHERE r.hosp_id IN (1)
              AND r.expecteddonedate >= TRUNC(SYSDATE)
              AND r.expecteddonedate < TRUNC(SYSDATE) + 1
              AND r.servstatus NOT IN ('C', 'F')
              AND r.sched_serial IS NOT NULL
            GROUP BY pt.cat_type
        ";

        return $this->normalizeOutpatientCashCreditRows(
            Database::fetchAll($this->hospitalCode, $sql)
        );
    }

    /** @return array<int, array<string, mixed>> */
    public function dailyOutpatientTrend(int $days = 14): array
    {
        $days = max(7, min(60, $days));
        $filter = $this->outpatientFilterSql();
        $sql = "
            SELECT
                TRUNC(r.expecteddonedate) day_id,
                TO_CHAR(TRUNC(r.expecteddonedate), 'DD Mon') day_label,
                COUNT(1) value
            FROM rcp_visit v
            JOIN rcp_service_reservation r
              ON v.hosp_id = r.hosp_id
             AND v.patientid = r.patientid
             AND v.visit_id = r.visit_id
            WHERE v.classcode = 2
              AND TRUNC(r.expecteddonedate) >= TRUNC(SYSDATE) - :days_back
              AND TRUNC(r.expecteddonedate) <= TRUNC(SYSDATE)
              AND r.servstatus NOT IN ('C', 'F')
              AND r.sched_serial IS NOT NULL
              {$filter}
            GROUP BY TRUNC(r.expecteddonedate), TO_CHAR(TRUNC(r.expecteddonedate), 'DD Mon')
            ORDER BY TRUNC(r.expecteddonedate)
        ";

        return Database::fetchAll($this->hospitalCode, $sql, ['days_back' => $days - 1]);
    }

    /** @return array<int, array<string, mixed>> */
    public function topSpecialtiesByOpVisits(int $year): array
    {
        if (!$this->tableExists('KPI_DOCTOR_PERF_DETAIL_JOB')) {
            return [];
        }

        foreach (['YEARID', 'SPECIAL_SPEC_ID', 'SPECIAL_SPEC_EN_NAME', 'TOTAL_OP_VISITS'] as $column) {
            if (!$this->columnExists('KPI_DOCTOR_PERF_DETAIL_JOB', $column)) {
                return [];
            }
        }

        $sql = "
            SELECT specialty_name, value
            FROM (
                SELECT
                    special_spec_en_name specialty_name,
                    SUM(DISTINCT total_op_visits) value
                FROM kpi_doctor_perf_detail_job
                WHERE yearid = :year
                GROUP BY special_spec_id, special_spec_en_name
                HAVING SUM(DISTINCT total_op_visits) > 0
                ORDER BY SUM(DISTINCT total_op_visits) DESC
            )
            WHERE ROWNUM <= 10
        ";

        return Database::fetchAll($this->hospitalCode, $sql, ['year' => $year]);
    }

    /** @return array<int, array<string, mixed>> */
    public function emptyMonthlyRows(): array
    {
        $rows = [];
        $months = [
            1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
            5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
            9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December',
        ];
        foreach ($months as $id => $name) {
            $rows[] = [
                'month_id' => $id,
                'month_name' => $name,
                'current_value' => 0.0,
                'compare_value' => 0.0,
            ];
        }
        return $rows;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function monthlyVisitRows(
        string $type,
        int $year,
        int $compareYear,
        ?string $fromDate,
        ?string $toDate
    ): array {
        $filter = $type === 'emergency' ? $this->emergencyFilterSql() : $this->outpatientFilterSql();

        if ($fromDate !== null && $toDate !== null) {
            $compareFrom = RevenueRepository::shiftDateToYear($fromDate, $compareYear);
            $compareTo = RevenueRepository::shiftDateToYear($toDate, $compareYear);
            $sql = "
                WITH months AS (
                    SELECT code month_id, name_en month_name
                    FROM sec_lookup_detail
                    WHERE parent_id = 1
                ),
                agg AS (
                    SELECT
                        'CURRENT' period_key,
                        TO_NUMBER(TO_CHAR(r.expecteddonedate, 'MM')) month_id,
                        COUNT(1) value
                    FROM rcp_visit v
                    JOIN rcp_service_reservation r
                      ON v.hosp_id = r.hosp_id
                     AND v.patientid = r.patientid
                     AND v.visit_id = r.visit_id
                    WHERE v.classcode = 2
                      AND r.expecteddonedate >= TO_DATE(:current_from, 'YYYY-MM-DD')
                      AND r.expecteddonedate < TO_DATE(:current_to, 'YYYY-MM-DD') + 1
                      AND r.servstatus NOT IN ('C', 'F')
                      AND r.sched_serial IS NOT NULL
                      {$filter}
                    GROUP BY TO_NUMBER(TO_CHAR(r.expecteddonedate, 'MM'))

                    UNION ALL

                    SELECT
                        'COMPARE' period_key,
                        TO_NUMBER(TO_CHAR(r.expecteddonedate, 'MM')) month_id,
                        COUNT(1) value
                    FROM rcp_visit v
                    JOIN rcp_service_reservation r
                      ON v.hosp_id = r.hosp_id
                     AND v.patientid = r.patientid
                     AND v.visit_id = r.visit_id
                    WHERE v.classcode = 2
                      AND r.expecteddonedate >= TO_DATE(:compare_from, 'YYYY-MM-DD')
                      AND r.expecteddonedate < TO_DATE(:compare_to, 'YYYY-MM-DD') + 1
                      AND r.servstatus NOT IN ('C', 'F')
                      AND r.sched_serial IS NOT NULL
                      {$filter}
                    GROUP BY TO_NUMBER(TO_CHAR(r.expecteddonedate, 'MM'))
                )
                SELECT
                    m.month_id,
                    m.month_name,
                    NVL(MAX(CASE WHEN a.period_key = 'CURRENT' THEN a.value END), 0) current_value,
                    NVL(MAX(CASE WHEN a.period_key = 'COMPARE' THEN a.value END), 0) compare_value
                FROM months m
                LEFT JOIN agg a ON a.month_id = m.month_id
                GROUP BY m.month_id, m.month_name
                ORDER BY m.month_id
            ";

            return $this->normalizeMonthlyRows(Database::fetchAll($this->hospitalCode, $sql, [
                'current_from' => $fromDate,
                'current_to' => $toDate,
                'compare_from' => $compareFrom,
                'compare_to' => $compareTo,
            ]));
        }

        $sql = "
            WITH p AS (
                SELECT :current_year current_year, :compare_year compare_year FROM dual
            ),
            months AS (
                SELECT code month_id, name_en month_name
                FROM sec_lookup_detail
                WHERE parent_id = 1
            ),
            agg AS (
                SELECT
                    EXTRACT(YEAR FROM r.expecteddonedate) year_id,
                    TO_NUMBER(TO_CHAR(r.expecteddonedate, 'MM')) month_id,
                    COUNT(1) value
                FROM rcp_visit v
                JOIN rcp_service_reservation r
                  ON v.hosp_id = r.hosp_id
                 AND v.patientid = r.patientid
                 AND v.visit_id = r.visit_id
                CROSS JOIN p
                WHERE v.classcode = 2
                  AND EXTRACT(YEAR FROM r.expecteddonedate) IN (p.current_year, p.compare_year)
                  AND r.servstatus NOT IN ('C', 'F')
                  AND r.sched_serial IS NOT NULL
                  {$filter}
                GROUP BY
                    EXTRACT(YEAR FROM r.expecteddonedate),
                    TO_NUMBER(TO_CHAR(r.expecteddonedate, 'MM'))
            )
            SELECT
                m.month_id,
                m.month_name,
                NVL(MAX(CASE WHEN a.year_id = p.current_year THEN a.value END), 0) current_value,
                NVL(MAX(CASE WHEN a.year_id = p.compare_year THEN a.value END), 0) compare_value
            FROM months m
            CROSS JOIN p
            LEFT JOIN agg a ON a.month_id = m.month_id
            GROUP BY m.month_id, m.month_name
            ORDER BY m.month_id
        ";

        return $this->monthlyRows($sql, $year, $compareYear);
    }

    public static function shiftDateToYear(string $date, int $targetYear): string
    {
        return RevenueRepository::shiftDateToYear($date, $targetYear);
    }

    private function outpatientFilterSql(): string
    {
        $emergencyType = $this->columnExpression('OUTPTA_EMERGENCY_TYPE', [
            'RCP_VISIT' => 'v',
            'RCP_SERVICE_RESERVATION' => 'r',
        ]);
        $clinicId = $this->columnExpression('CLINIC_ID', [
            'RCP_VISIT' => 'v',
            'RCP_SERVICE_RESERVATION' => 'r',
        ]);

        $parts = [];
        if ($emergencyType !== null) {
            $parts[] = 'NVL(' . $emergencyType . ', 0) <> 1';
        }
        if ($clinicId !== null) {
            $parts[] = $clinicId . ' NOT IN (16, 297, 298)';
        }

        return $parts ? 'AND (' . implode(' AND ', $parts) . ')' : '';
    }

    private function emergencyFilterSql(): string
    {
        $emergencyType = $this->columnExpression('OUTPTA_EMERGENCY_TYPE', [
            'RCP_VISIT' => 'v',
            'RCP_SERVICE_RESERVATION' => 'r',
        ]);
        $clinicId = $this->columnExpression('CLINIC_ID', [
            'RCP_VISIT' => 'v',
            'RCP_SERVICE_RESERVATION' => 'r',
        ]);

        $parts = [];
        if ($emergencyType !== null) {
            $parts[] = 'NVL(' . $emergencyType . ', 0) = 1';
        }
        if ($clinicId !== null) {
            $parts[] = 'NVL(' . $clinicId . ', 0) IN (16, 297, 298)';
        }

        return $parts ? 'AND (' . implode(' OR ', $parts) . ')' : 'AND 1 = 0';
    }

    /**
     * @param array<string, string> $tableAliasMap table name => SQL alias
     */
    private function columnExpression(string $column, array $tableAliasMap): ?string
    {
        foreach ($tableAliasMap as $table => $alias) {
            if ($this->columnExists($table, $column)) {
                return $alias . '.' . strtolower($column);
            }
        }
        return null;
    }

    private function columnExists(string $table, string $column): bool
    {
        $schema = strtoupper((string)\config('hospitals.' . $this->hospitalCode . '.database.schema', ''));
        $cacheKey = $schema . '.' . strtoupper($table) . '.' . strtoupper($column);
        if (array_key_exists($cacheKey, $this->columnCache)) {
            return $this->columnCache[$cacheKey];
        }

        $sql = "
            SELECT 1 ok
            FROM all_tab_columns
            WHERE table_name = UPPER(:table_name)
              AND column_name = UPPER(:column_name)
              AND (:owner_name IS NULL OR owner = UPPER(:owner_name))
              AND ROWNUM <= 1
        ";

        try {
            $rows = Database::fetchAll($this->hospitalCode, $sql, [
                'table_name' => $table,
                'column_name' => $column,
                'owner_name' => $schema !== '' ? $schema : null,
            ]);
            $this->columnCache[$cacheKey] = !empty($rows);
            return $this->columnCache[$cacheKey];
        } catch (Throwable $e) {
            $this->columnCache[$cacheKey] = false;
            return false;
        }
    }

    private function tableExists(string $table): bool
    {
        $schema = strtoupper((string)\config('hospitals.' . $this->hospitalCode . '.database.schema', ''));
        $cacheKey = $schema . '.' . strtoupper($table);
        if (array_key_exists($cacheKey, $this->tableCache)) {
            return $this->tableCache[$cacheKey];
        }

        $sql = "
            SELECT 1 ok
            FROM all_tables
            WHERE table_name = UPPER(:table_name)
              AND (:owner_name IS NULL OR owner = UPPER(:owner_name))
              AND ROWNUM <= 1
        ";

        try {
            $rows = Database::fetchAll($this->hospitalCode, $sql, [
                'table_name' => $table,
                'owner_name' => $schema !== '' ? $schema : null,
            ]);
            $this->tableCache[$cacheKey] = !empty($rows);
            return $this->tableCache[$cacheKey];
        } catch (Throwable $e) {
            $this->tableCache[$cacheKey] = false;
            return false;
        }
    }

    /** @return array<int, array<string, mixed>> */
    private function monthlyRows(string $sql, int $year, int $compareYear): array
    {
        return $this->normalizeMonthlyRows(Database::fetchAll($this->hospitalCode, $sql, [
            'current_year' => $year,
            'compare_year' => $compareYear,
        ]));
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     * @return array<int, array<string, mixed>>
     */
    private function normalizeMonthlyRows(array $rows): array
    {
        return array_map(static function (array $row): array {
            return [
                'month_id' => (int)($row['month_id'] ?? $row['code'] ?? 0),
                'month_name' => trim((string)($row['month_name'] ?? '')),
                'current_value' => (float)($row['current_value'] ?? 0),
                'compare_value' => (float)($row['compare_value'] ?? 0),
            ];
        }, $rows);
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     * @return array<int, array<string, mixed>>
     */
    /**
     * @param array<int, array<string, mixed>> $rows
     * @return array<int, array{category:string,value:float}>
     */
    private function normalizeAdmissionCategoryRows(array $rows): array
    {
        $out = [
            'cash' => ['category' => 'cash', 'value' => 0.0],
            'credit' => ['category' => 'credit', 'value' => 0.0],
            'insurance' => ['category' => 'insurance', 'value' => 0.0],
        ];

        foreach ($rows as $row) {
            $category = strtolower(trim((string)($row['category'] ?? 'other')));
            if (!array_key_exists($category, $out)) {
                $category = 'other';
                if (!isset($out[$category])) {
                    $out[$category] = ['category' => 'other', 'value' => 0.0];
                }
            }
            $out[$category]['value'] += (float)($row['value'] ?? 0);
        }

        return array_values($out);
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     * @return array<int, array{category:string,value:float}>
     */
    private function normalizeOutpatientCashCreditRows(array $rows): array
    {
        $out = [
            'cash' => ['category' => 'cash', 'value' => 0.0],
            'credit' => ['category' => 'credit', 'value' => 0.0],
        ];

        foreach ($rows as $row) {
            $category = strtolower(trim((string)($row['category'] ?? 'credit')));
            $key = $category === 'cash' ? 'cash' : 'credit';
            $out[$key]['value'] += (float)($row['value'] ?? 0);
        }

        return array_values($out);
    }

    private function normalizeMixRows(array $rows): array
    {
        $out = [
            'new' => ['category' => 'new', 'value' => 0.0],
            'followup' => ['category' => 'followup', 'value' => 0.0],
        ];
        foreach ($rows as $row) {
            $category = strtolower(trim((string)($row['category'] ?? 'new')));
            $key = $category === 'followup' ? 'followup' : 'new';
            $out[$key]['value'] += (float)($row['value'] ?? 0);
        }
        return array_values($out);
    }
}
