<?php

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Models\CodeDetailModel;
use App\Models\CodeKindModel;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use Config\Roles;

class DesignatedShop extends BaseController
{
    private DesignatedShopModel $shopModel;
    private LocalGovernmentModel $lgModel;
    private Roles $roles;
    private ?bool $dongColumnExists = null;

    public function __construct()
    {
        $this->shopModel = model(DesignatedShopModel::class);
        $this->lgModel   = model(LocalGovernmentModel::class);
        $this->roles     = config('Roles');
    }

    private function isSuperAdmin(): bool
    {
        return Roles::isSuperAdminEquivalent((int) session()->get('mb_level'));
    }

    private function isLocalAdmin(): bool
    {
        return (int) session()->get('mb_level') === Roles::LEVEL_LOCAL_ADMIN;
    }

    /**
     * DB 행에서 컬럼이 없을 수 있는 환경(구 스키마)까지 고려한 문자열 읽기.
     */
    private function designatedShopScalar(object $row, string $field): string
    {
        return isset($row->{$field}) ? (string) $row->{$field} : '';
    }

    /**
     * DATE 컬럼을 상세 JSON/화면용 문자열로.
     */
    private function designatedShopDateOut(object $row, string $field): string
    {
        if (! isset($row->{$field})) {
            return '';
        }
        $da = $row->{$field};
        if ($da === null || $da === '' || $da === '0000-00-00') {
            return '';
        }

        return (string) $da;
    }

    /**
     * 가상계좌: 은행·계좌번호 분리 입력 + 구 단일 필드(ds_va_number) 하위 호환.
     *
     * @return array{ds_va_bank: string, ds_va_account: string, ds_va_number: string}
     */
    private function resolveVirtualAccountFromRequest(): array
    {
        $bank    = trim((string) $this->request->getPost('ds_va_bank'));
        $account = trim((string) $this->request->getPost('ds_va_account'));
        $legacy  = trim((string) $this->request->getPost('ds_va_number'));
        if ($account === '' && $legacy !== '') {
            $account = $legacy;
        }
        $number = $account !== '' ? $account : $legacy;

        return [
            'ds_va_bank'    => $bank,
            'ds_va_account' => $account,
            'ds_va_number'  => $number,
        ];
    }

    private function normalizeOptionalDate(?string $raw): ?string
    {
        $s = trim((string) ($raw ?? ''));
        if ($s === '' || $s === '0000-00-00') {
            return null;
        }
        $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $s);

        return ($dt !== false && $dt->format('Y-m-d') === $s) ? $s : null;
    }

    /**
     * 주소 문자열 비교용(공백 제거).
     */
    private function compactAddressText(string $s): string
    {
        return preg_replace('/\s+/u', '', trim($s)) ?? '';
    }

    /**
     * 시·도 또는 구·군 명칭이 지자체 마스터와 맞는지 본다.
     * - 카카오 `sido`/`sigungu` 또는 도로명·지번 전체에 `lg_sido`·`lg_gugun`이 포함되면 허용.
     */
    private function koreanRegionTokenMatches(string $lgNeedle, string $primaryToken, string $fullBlob): bool
    {
        $needle = $this->compactAddressText($lgNeedle);
        if ($needle === '') {
            return true;
        }
        $blob = $this->compactAddressText($fullBlob);
        if ($blob !== '' && mb_stripos($blob, $needle, 0, 'UTF-8') !== false) {
            return true;
        }
        $primary = $this->compactAddressText($primaryToken);
        if ($primary !== '') {
            if (mb_stripos($primary, $needle, 0, 'UTF-8') !== false) {
                return true;
            }
            if (mb_stripos($needle, $primary, 0, 'UTF-8') !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * 우편·도로명·지번 중 하나라도 있으면, 효과 지자체(`lg_sido`, `lg_gugun`) 관할인지 검사한다.
     */
    private function isDesignatedShopAddressWithinLocalGovernment(
        object $lg,
        string $addrSido,
        string $addrSigungu,
        string $road,
        string $jibun,
        string $zip
    ): bool {
        $road  = trim($road);
        $jibun = trim($jibun);
        $zip   = trim($zip);
        if ($road === '' && $jibun === '' && $zip === '') {
            return true;
        }

        $lgSido  = trim((string) ($lg->lg_sido ?? ''));
        $lgGugun = trim((string) ($lg->lg_gugun ?? ''));
        $blob    = trim($addrSido . ' ' . $addrSigungu . ' ' . $road . ' ' . $jibun . ' ' . $zip);

        if ($lgSido !== '' && ! $this->koreanRegionTokenMatches($lgSido, $addrSido, $blob)) {
            return false;
        }

        if ($lgGugun !== '' && ! $this->koreanRegionTokenMatches($lgGugun, $addrSigungu, $blob)) {
            return false;
        }

        return true;
    }

    private function hasDesignatedShopPostalAddress(string $zip, string $road, string $jibun): bool
    {
        return trim($zip . $road . $jibun) !== '';
    }

    /**
     * 우편·도로명·지번이 있으면 카카오 검색으로만 채웠는지(시도 hidden) 확인한다.
     */
    private function designatedShopAddressFilledWithoutSearch(string $addrSido, string $zip, string $road, string $jibun): bool
    {
        return $this->hasDesignatedShopPostalAddress($zip, $road, $jibun) && trim($addrSido) === '';
    }

    /**
     * 목록 검색과 동일한 조건을 모델 쿼리에 적용한다.
     */
    private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): void
    {
        $model->where('ds_lg_idx', $lgIdx);
        if ($dsName !== null && $dsName !== '') {
            $model->like('ds_name', $dsName);
        }
        if ($dsGugunCode !== null && $dsGugunCode !== '') {
            $model->where('ds_gugun_code', $dsGugunCode);
        }
        if ($dsState !== null && $dsState !== '') {
            $model->where('ds_state', (int) $dsState);
        }
    }

    /**
     * @return array{1: int, 2: int, 3: int, total: int}
     */
    private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array
    {
        $db      = \Config\Database::connect();
        $builder = $db->table('designated_shop');
        $builder->where('ds_lg_idx', $lgIdx);
        if ($dsName !== null && $dsName !== '') {
            $builder->like('ds_name', $dsName);
        }
        if ($dsGugunCode !== null && $dsGugunCode !== '') {
            $builder->where('ds_gugun_code', $dsGugunCode);
        }
        if ($dsState !== null && $dsState !== '') {
            $builder->where('ds_state', (int) $dsState);
        }
        $rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
            ->groupBy('ds_state')
            ->get()
            ->getResultArray();
        $counts = [1 => 0, 2 => 0, 3 => 0];
        foreach ($rows as $r) {
            $st = (int) ($r['ds_state'] ?? 0);
            if (isset($counts[$st])) {
                $counts[$st] = (int) $r['cnt'];
            }
        }
        $counts['total'] = $counts[1] + $counts[2] + $counts[3];

        return $counts;
    }

    /**
     * @param list<object> $list
     * @param array<int, string> $lgMap
     * @param array<int, array{code: string, name: string}> $dongMap  ds_idx => [동코드, 동명]
     * @return list<array<string, mixed>>
     */
    private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx() ?? 0;
        $gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
        $payload = [];
        foreach ($list as $row) {
            $sn = (string) ($row->ds_shop_no ?? '');
            if (preg_match('/(\d{3})$/', $sn, $m)) {
                $shortNo = $m[1];
            } elseif ($sn !== '' && strlen($sn) >= 3) {
                $shortNo = substr($sn, -3);
            } else {
                $shortNo = $sn;
            }
            $st        = (int) ($row->ds_state ?? 1);
            $stateMap  = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
            $da        = $row->ds_designated_at ?? null;
            $daOut     = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : '';

            $idxKey    = (int) ($row->ds_idx ?? 0);
            $dongCode  = $dongMap[$idxKey]['code'] ?? (string) ($row->ds_dong_code ?? '');
            $dongName  = $dongMap[$idxKey]['name'] ?? '';
            $gugunName = $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? '');
            // 구·군 표시값: 동코드가 있으면 "동코드 (동명)", 없으면 기존 구·군명
            if ($dongCode !== '') {
                $dongDisplay = $dongCode . ($dongName !== '' ? ' (' . $dongName . ')' : '');
            } else {
                $dongDisplay = $gugunName;
            }

            $payload[] = [
                'ds_idx'          => (int) $row->ds_idx,
                'ds_shop_no'      => $sn,
                'shop_no_display' => $shortNo,
                'ds_rep_name'     => (string) ($row->ds_rep_name ?? ''),
                'ds_name'         => (string) ($row->ds_name ?? ''),
                'ds_state'        => $st,
                'state_label'     => $stateMap[$st] ?? '',
                'ds_biz_no'       => (string) ($row->ds_biz_no ?? ''),
                'ds_biz_type'     => $this->designatedShopScalar($row, 'ds_biz_type'),
                'ds_biz_kind'     => $this->designatedShopScalar($row, 'ds_biz_kind'),
                'ds_va_number'    => (string) ($row->ds_va_number ?? ''),
                'ds_va_bank'      => $this->designatedShopScalar($row, 'ds_va_bank'),
                'ds_va_account'   => $this->designatedShopScalar($row, 'ds_va_account'),
                'ds_zip'          => (string) ($row->ds_zip ?? ''),
                'ds_addr'         => (string) ($row->ds_addr ?? ''),
                'ds_addr_jibun'   => (string) ($row->ds_addr_jibun ?? ''),
                'ds_addr_detail'  => $this->designatedShopScalar($row, 'ds_addr_detail'),
                'ds_tel'          => (string) ($row->ds_tel ?? ''),
                'ds_rep_phone'    => (string) ($row->ds_rep_phone ?? ''),
                'ds_email'        => (string) ($row->ds_email ?? ''),
                'ds_gugun_code'   => (string) ($row->ds_gugun_code ?? ''),
                'gugun_name'      => $gugunName,
                'ds_dong_code'    => $dongCode,
                'dong_name'       => $dongName,
                'dong_display'    => $dongDisplay,
                'ds_zone_code'    => $this->designatedShopScalar($row, 'ds_zone_code'),
                'ds_branch_no'    => $this->designatedShopScalar($row, 'ds_branch_no'),
                'ds_designated_at' => $daOut,
                'ds_state_changed_at' => $this->designatedShopDateOut($row, 'ds_state_changed_at'),
                'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
                'ds_regdate'      => (string) ($row->ds_regdate ?? ''),
                'lg_name'         => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
            ];
        }

        return $payload;
    }

    /**
     * 목록·검색·상세 표시에 공통으로 쓰는 뷰 데이터 (지자체 미선택 시 null).
     *
     * @return array<string, mixed>|null
     */
    private function designatedShopIndexViewData(): ?array
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();

        if ($lgIdx === null || $lgIdx <= 0) {
            return null;
        }

        // 다조건 검색 (P2-15)
        $dsName      = $this->request->getGet('ds_name');
        $dsGugunCode = $this->request->getGet('ds_gugun_code');
        $dsState     = $this->request->getGet('ds_state');
        $this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState);

        // 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
        $list  = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
        $pager = null;

        // 지자체 이름 매핑용
        $lgMap = [];
        foreach ($this->lgModel->findAll() as $lg) {
            $lgMap[$lg->lg_idx] = $lg->lg_name;
        }

        $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState);
        $gugunNameMap = $this->gugunCodeNameMap($lgIdx);

        // 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
        $dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
        $this->persistDesignatedShopDongCodes($list, $dongMap);
        $detailRows  = $this->buildDesignatedShopDetailPayload($list, $lgMap, $dongMap);

        // 리스트(서버 렌더)에서 동코드 표시용: ds_idx => "동코드 (동명)"
        $dongDisplayMap = [];
        foreach ($dongMap as $idx => $d) {
            $code = (string) ($d['code'] ?? '');
            $name = (string) ($d['name'] ?? '');
            if ($code !== '') {
                $dongDisplayMap[$idx] = $code . ($name !== '' ? ' (' . $name . ')' : '');
            }
        }

        // 구군코드 목록 (검색 필터용)
        $db         = \Config\Database::connect();
        $gugunCodes = $db->query("SELECT DISTINCT ds_gugun_code FROM designated_shop WHERE ds_lg_idx = ? AND ds_gugun_code != '' ORDER BY ds_gugun_code", [$lgIdx])->getResult();

        return [
            'list'               => $list,
            'lgMap'              => $lgMap,
            'pager'              => $pager,
            'dsName'             => $dsName ?? '',
            'dsGugunCode'        => $dsGugunCode ?? '',
            'dsState'            => $dsState ?? '',
            'gugunCodes'         => $gugunCodes,
            'stateCounts'        => $stateCounts,
            'gugunNameMap'       => $gugunNameMap,
            'dongDisplayMap'     => $dongDisplayMap,
            'detailRowsJson'     => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
            'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
        ];
    }

    /**
     * 지정판매소 목록 (효과 지자체 기준: super admin = 선택 지자체, 지자체관리자 = mb_lg_idx)
     */
    public function index()
    {
        $data = $this->designatedShopIndexViewData();
        if ($data === null) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
        }
        $data['readOnly'] = false;

        return $this->renderWorkPage('지정판매소 관리', 'admin/designated_shop/index', $data);
    }

    /**
     * 지정판매소 조회 전용 (목록·검색·상세만, 등록·수정·삭제·엑셀 없음)
     */
    public function browse()
    {
        $data = $this->designatedShopIndexViewData();
        if ($data === null) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
        }
        $data['readOnly'] = true;

        return $this->renderWorkPage('지정판매소 조회', 'admin/designated_shop/manage', $data);
    }

    /**
     * 지정판매소 바코드 출력 전용 목록 (선택 후 인쇄).
     */
    public function barcode()
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
        }

        $currentLg = $this->lgModel->find($lgIdx);
        $fixedGugunCode = trim((string) ($currentLg->lg_code ?? ''));
        $fixedGugunLabel = trim((string) ($currentLg->lg_gugun ?? ''));
        if ($fixedGugunLabel === '') {
            $fixedGugunLabel = trim((string) ($currentLg->lg_name ?? ''));
        }

        $zone = trim((string) $this->request->getGet('ds_zone_code'));
        $order = trim((string) $this->request->getGet('order_by'));
        if (! in_array($order, ['shop_no', 'name'], true)) {
            $order = 'shop_no';
        }

        $builder = $this->shopModel->where('ds_lg_idx', $lgIdx);
        if ($fixedGugunCode !== '') {
            $builder->where('ds_gugun_code', $fixedGugunCode);
        }
        if ($zone !== '') {
            $builder->where('ds_zone_code', $zone);
        }
        if ($order === 'name') {
            $builder->orderBy('ds_name', 'ASC');
        } else {
            $builder->orderBy('ds_shop_no', 'ASC');
        }
        $builder->orderBy('ds_idx', 'ASC');
        $list = $builder->paginate(100);

        $db = \Config\Database::connect();
        $zones = $db->query(
            "SELECT DISTINCT TRIM(ds_zone_code) AS zone_code
             FROM designated_shop
             WHERE ds_lg_idx = ?
               AND (? = '' OR ds_gugun_code = ?)
               AND TRIM(ds_zone_code) != ''
             ORDER BY zone_code",
            [$lgIdx, $fixedGugunCode, $fixedGugunCode]
        )->getResult();

        return $this->renderWorkPage('지정판매소 바코드 출력', 'admin/designated_shop/barcode', [
            'list'            => $list,
            'pager'           => $this->shopModel->pager,
            'fixedGugunLabel' => $fixedGugunLabel,
            'zoneFilter'      => $zone,
            'zones'           => $zones,
            'orderBy'         => $order,
        ]);
    }

    /**
     * 지정판매소 바코드 인쇄 페이지 (선택된 판매소 기준).
     */
    public function barcodePrint()
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
        }

        $ids = $this->request->getPost('ds_idx');
        $ids = is_array($ids) ? array_values(array_unique(array_map('intval', $ids))) : [];
        $ids = array_values(array_filter($ids, static fn ($v): bool => $v > 0));
        if ($ids === []) {
            return redirect()->to(mgmt_url('designated-shops/barcode'))
                ->with('error', '출력할 지정판매소를 선택해 주세요.');
        }

        $rows = $this->shopModel
            ->where('ds_lg_idx', $lgIdx)
            ->whereIn('ds_idx', $ids)
            ->orderBy('ds_shop_no', 'ASC')
            ->findAll();
        if ($rows === []) {
            return redirect()->to(mgmt_url('designated-shops/barcode'))
                ->with('error', '선택한 지정판매소를 찾을 수 없습니다.');
        }

        $zoneLabel = trim((string) $this->request->getPost('zone_label'));
        if ($zoneLabel === '') {
            $firstZone = trim((string) ($rows[0]->ds_zone_code ?? ''));
            $zoneLabel = $firstZone !== '' ? $firstZone : '전체';
        }

        return view('admin/designated_shop/barcode_print', [
            'rows'       => $rows,
            'zoneLabel'  => $zoneLabel,
            'printedAt'  => date('Y.m.d'),
            'totalCount' => count($rows),
        ]);
    }

    public function export()
    {
        helper(['admin', 'export']);
        $lgIdx = admin_effective_lg_idx();
        if (!$lgIdx) {
            return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
        }

        $list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();

        // 동코드(구·군 동코드) 산출
        $dongMap = $this->resolveDongCodesForShops($lgIdx, $list);

        $rows = [];
        foreach ($list as $row) {
            $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
            $idxKey   = (int) ($row->ds_idx ?? 0);
            $dCode    = (string) ($dongMap[$idxKey]['code'] ?? '');
            $dName    = (string) ($dongMap[$idxKey]['name'] ?? '');
            $dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
            $da       = $row->ds_designated_at ?? null;
            $daDisp   = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
            $rows[] = [
                $row->ds_idx,
                $row->ds_shop_no,
                $row->ds_name,
                $row->ds_rep_name,
                $dongDisp,
                $daDisp,
                $row->ds_biz_no,
                $this->designatedShopScalar($row, 'ds_biz_type'),
                $this->designatedShopScalar($row, 'ds_biz_kind'),
                $this->designatedShopScalar($row, 'ds_va_bank'),
                $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''),
                $row->ds_tel ?? '',
                $row->ds_addr ?? '',
                $this->designatedShopScalar($row, 'ds_zone_code'),
                $this->designatedShopScalar($row, 'ds_branch_no'),
                $this->designatedShopDateOut($row, 'ds_state_changed_at'),
                $this->designatedShopScalar($row, 'ds_change_reason'),
                $stateMap[(int) $row->ds_state] ?? '',
                $row->ds_regdate ?? '',
            ];
        }

        export_csv(
            '지정판매소_' . date('Ymd') . '.csv',
            [
                '번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
                '가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
                '변경일자', '변경사유', '상태', '등록일',
            ],
            $rows
        );
    }

    /**
     * 지정판매소 등록 폼 (효과 지자체 기준)
     */
    public function create()
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
        }

        $currentLg = $this->lgModel->find($lgIdx);
        if ($currentLg === null) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '선택한 지자체 정보를 찾을 수 없습니다.');
        }

        return $this->renderWorkPage('지정판매소 등록', 'admin/designated_shop/create', [
            'localGovs'          => [],
            'currentLg'          => $currentLg,
            'addrTenantScope'    => [
                'lg_sido'  => (string) ($currentLg->lg_sido ?? ''),
                'lg_gugun' => (string) ($currentLg->lg_gugun ?? ''),
            ],
            'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
        ]);
    }

    /**
     * 지정판매소 등록 처리
     */
    public function store()
    {
        if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '지정판매소 등록은 관리자만 가능합니다.');
        }

        $rules = [
            'ds_name'              => 'required|max_length[100]',
            'ds_biz_no'            => 'required|max_length[20]',
            'ds_rep_name'          => 'required|max_length[50]',
            'ds_biz_type'          => 'permit_empty|max_length[100]',
            'ds_biz_kind'          => 'permit_empty|max_length[100]',
            'ds_va_number'         => 'permit_empty|max_length[50]',
            'ds_va_bank'           => 'permit_empty|max_length[80]',
            'ds_va_account'        => 'permit_empty|max_length[50]',
            'ds_email'             => 'permit_empty|valid_email|max_length[100]',
            'ds_zone_code'         => 'permit_empty|max_length[80]',
            'ds_branch_no'         => 'permit_empty|max_length[50]',
            'ds_change_reason'     => 'permit_empty|max_length[500]',
            'ds_state_changed_at'  => 'permit_empty|max_length[10]',
            'addr_search_sido'     => 'permit_empty|max_length[80]',
            'addr_search_sigungu'  => 'permit_empty|max_length[80]',
            'ds_addr_detail'       => 'permit_empty|max_length[200]',
        ];

        if (! $this->validate($rules)) {
            return redirect()->back()
                ->withInput()
                ->with('errors', $this->validator->getErrors());
        }

        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->back()
                ->withInput()
                ->with('error', '소속 지자체가 올바르지 않습니다.');
        }

        $lg = $this->lgModel->find($lgIdx);
        if ($lg === null || (string) $lg->lg_code === '') {
            return redirect()->back()
                ->withInput()
                ->with('error', '지자체 코드 정보를 찾을 수 없습니다.');
        }

        $addrSido    = (string) $this->request->getPost('addr_search_sido');
        $addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
        $dsAddr      = (string) $this->request->getPost('ds_addr');
        $dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
        $dsZip       = (string) $this->request->getPost('ds_zip');
        if ($this->designatedShopAddressFilledWithoutSearch($addrSido, $dsZip, $dsAddr, $dsAddrJibun)) {
            return redirect()->back()
                ->withInput()
                ->with('error', '주소는 「주소 검색」으로만 지정할 수 있습니다.');
        }
        if (! $this->isDesignatedShopAddressWithinLocalGovernment($lg, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip)) {
            return redirect()->back()
                ->withInput()
                ->with('error', '작업 중인 지자체(' . (string) $lg->lg_name . ') 관할 주소만 등록할 수 있습니다. 주소 검색으로 선택하거나 시·구에 맞게 입력해 주세요.');
        }

        $resolvedNo = $this->resolveDesignatedShopNumberFromAddress(
            $lgIdx,
            $addrSido,
            $addrSigungu,
            $dsAddr,
            $dsAddrJibun,
            $dsZip,
            $lg
        );
        if (! $resolvedNo['ok']) {
            return redirect()->back()
                ->withInput()
                ->with('error', $resolvedNo['error']);
        }

        $va = $this->resolveVirtualAccountFromRequest();

        $data = [
            'ds_lg_idx'            => $lgIdx,
            'ds_mb_idx'            => null,
            'ds_shop_no'           => $resolvedNo['shop_no'],
            'ds_name'              => (string) $this->request->getPost('ds_name'),
            'ds_biz_no'            => (string) $this->request->getPost('ds_biz_no'),
            'ds_rep_name'          => (string) $this->request->getPost('ds_rep_name'),
            'ds_biz_type'          => (string) $this->request->getPost('ds_biz_type'),
            'ds_biz_kind'          => (string) $this->request->getPost('ds_biz_kind'),
            'ds_va_bank'           => $va['ds_va_bank'],
            'ds_va_account'        => $va['ds_va_account'],
            'ds_va_number'         => $va['ds_va_number'],
            'ds_zip'               => (string) $this->request->getPost('ds_zip'),
            'ds_addr'              => (string) $this->request->getPost('ds_addr'),
            'ds_addr_jibun'        => (string) $this->request->getPost('ds_addr_jibun'),
            'ds_addr_detail'       => (string) $this->request->getPost('ds_addr_detail'),
            'ds_tel'               => (string) $this->request->getPost('ds_tel'),
            'ds_rep_phone'         => (string) $this->request->getPost('ds_rep_phone'),
            'ds_email'             => (string) $this->request->getPost('ds_email'),
            'ds_gugun_code'        => $resolvedNo['gugun_code'],
            'ds_zone_code'         => (string) $this->request->getPost('ds_zone_code'),
            'ds_branch_no'         => (string) $this->request->getPost('ds_branch_no'),
            'ds_designated_at'     => $this->request->getPost('ds_designated_at') ?: null,
            'ds_state'             => 1,
            // 동코드(컬럼이 있을 때만 저장)
            ...($this->designatedShopHasDongColumn() ? ['ds_dong_code' => (string) ($resolvedNo['dong_code'] ?? '')] : []),
            'ds_state_changed_at'  => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
            'ds_change_reason'     => (string) $this->request->getPost('ds_change_reason'),
            'ds_regdate'           => date('Y-m-d H:i:s'),
        ];

        $this->shopModel->insert($data);

        return redirect()->to(mgmt_url('designated-shops'))
            ->with('success', '지정판매소가 등록되었습니다.');
    }

    /**
     * 지정판매소 수정 폼 (효과 지자체 소속만 허용)
     * 문서: docs/기본 개발계획/23-지정판매소_수정_삭제_기능.md
     */
    public function edit(int $id)
    {
        if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '권한이 없습니다.');
        }

        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $shop = $this->shopModel->find($id);
        if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.');
        }

        $currentLg = $this->lgModel->find($lgIdx);

        return $this->renderWorkPage('지정판매소 수정', 'admin/designated_shop/edit', [
            'shop'               => $shop,
            'currentLg'          => $currentLg,
            'addrTenantScope'    => $currentLg !== null ? [
                'lg_sido'  => (string) ($currentLg->lg_sido ?? ''),
                'lg_gugun' => (string) ($currentLg->lg_gugun ?? ''),
            ] : ['lg_sido' => '', 'lg_gugun' => ''],
            'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
        ]);
    }

    /**
     * 지정판매소 수정 처리
     */
    public function update(int $id)
    {
        if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '권한이 없습니다.');
        }

        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $shop = $this->shopModel->find($id);
        if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.');
        }

        $rules = [
            'ds_name'              => 'required|max_length[100]',
            'ds_biz_no'            => 'required|max_length[20]',
            'ds_rep_name'          => 'required|max_length[50]',
            'ds_biz_type'          => 'permit_empty|max_length[100]',
            'ds_biz_kind'          => 'permit_empty|max_length[100]',
            'ds_va_number'         => 'permit_empty|max_length[50]',
            'ds_va_bank'           => 'permit_empty|max_length[80]',
            'ds_va_account'        => 'permit_empty|max_length[50]',
            'ds_email'             => 'permit_empty|valid_email|max_length[100]',
            'ds_state'             => 'permit_empty|in_list[1,2,3]',
            'ds_zone_code'         => 'permit_empty|max_length[80]',
            'ds_branch_no'         => 'permit_empty|max_length[50]',
            'ds_change_reason'     => 'permit_empty|max_length[500]',
            'ds_state_changed_at'  => 'permit_empty|max_length[10]',
            'addr_search_sido'     => 'permit_empty|max_length[80]',
            'addr_search_sigungu'  => 'permit_empty|max_length[80]',
            'ds_addr_detail'       => 'permit_empty|max_length[200]',
        ];

        if (! $this->validate($rules)) {
            return redirect()->back()
                ->withInput()
                ->with('errors', $this->validator->getErrors());
        }

        $lg = $this->lgModel->find($lgIdx);
        if ($lg === null) {
            return redirect()->back()
                ->withInput()
                ->with('error', '지자체 정보를 찾을 수 없습니다.');
        }

        $addrSido    = (string) $this->request->getPost('addr_search_sido');
        $addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
        $dsAddr      = (string) $this->request->getPost('ds_addr');
        $dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
        $dsZip       = (string) $this->request->getPost('ds_zip');
        if ($this->designatedShopAddressFilledWithoutSearch($addrSido, $dsZip, $dsAddr, $dsAddrJibun)) {
            return redirect()->back()
                ->withInput()
                ->with('error', '주소는 「주소 검색」으로만 지정할 수 있습니다.');
        }
        if (! $this->isDesignatedShopAddressWithinLocalGovernment($lg, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip)) {
            return redirect()->back()
                ->withInput()
                ->with('error', '작업 중인 지자체(' . (string) $lg->lg_name . ') 관할 주소만 입력할 수 있습니다. 주소 검색으로 선택하거나 시·구에 맞게 입력해 주세요.');
        }

        $va = $this->resolveVirtualAccountFromRequest();

        // 주소로 동코드 재산출(컬럼이 있을 때만 저장). 매칭 실패 시 동코드는 갱신하지 않음.
        $dongCodeData = [];
        if ($this->designatedShopHasDongColumn()) {
            $resolvedDong = $this->resolveDesignatedShopNumberFromAddress(
                $lgIdx,
                $addrSido,
                $addrSigungu,
                $dsAddr,
                $dsAddrJibun,
                $dsZip,
                $lg
            );
            if (($resolvedDong['ok'] ?? false) && ($resolvedDong['dong_code'] ?? '') !== '') {
                $dongCodeData = ['ds_dong_code' => (string) $resolvedDong['dong_code']];
            }
        }

        $data = [
            'ds_name'              => (string) $this->request->getPost('ds_name'),
            'ds_biz_no'            => (string) $this->request->getPost('ds_biz_no'),
            'ds_rep_name'          => (string) $this->request->getPost('ds_rep_name'),
            'ds_biz_type'          => (string) $this->request->getPost('ds_biz_type'),
            'ds_biz_kind'          => (string) $this->request->getPost('ds_biz_kind'),
            'ds_va_bank'           => $va['ds_va_bank'],
            'ds_va_account'        => $va['ds_va_account'],
            'ds_va_number'         => $va['ds_va_number'],
            'ds_zip'               => (string) $this->request->getPost('ds_zip'),
            'ds_addr'              => (string) $this->request->getPost('ds_addr'),
            'ds_addr_jibun'        => (string) $this->request->getPost('ds_addr_jibun'),
            'ds_addr_detail'       => (string) $this->request->getPost('ds_addr_detail'),
            'ds_tel'               => (string) $this->request->getPost('ds_tel'),
            'ds_rep_phone'         => (string) $this->request->getPost('ds_rep_phone'),
            'ds_email'             => (string) $this->request->getPost('ds_email'),
            ...$dongCodeData,
            'ds_zone_code'         => (string) $this->request->getPost('ds_zone_code'),
            'ds_branch_no'         => (string) $this->request->getPost('ds_branch_no'),
            'ds_designated_at'     => $this->request->getPost('ds_designated_at') ?: null,
            'ds_state'             => (int) ($this->request->getPost('ds_state') ?: 1),
            'ds_state_changed_at'  => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
            'ds_change_reason'     => (string) $this->request->getPost('ds_change_reason'),
        ];

        $this->shopModel->update($id, $data);

        return redirect()->to(mgmt_url('designated-shops'))
            ->with('success', '지정판매소 정보가 수정되었습니다.');
    }

    /**
     * 지정판매소 삭제 (물리 삭제, 효과 지자체 소속만 허용)
     * 문서: docs/기본 개발계획/23-지정판매소_수정_삭제_기능.md
     */
    public function delete(int $id)
    {
        if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '권한이 없습니다.');
        }

        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $shop = $this->shopModel->find($id);
        if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) {
            return redirect()->to(mgmt_url('designated-shops'))
                ->with('error', '해당 지정판매소를 찾을 수 없거나 삭제할 수 없습니다.');
        }

        $this->shopModel->delete($id);

        return redirect()->to(mgmt_url('designated-shops'))
            ->with('success', '지정판매소가 삭제되었습니다.');
    }

    /**
     * P2-17: 지정판매소 지도 표시
     */
    public function map()
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $shops = $this->shopModel
            ->where('ds_lg_idx', $lgIdx)
            ->where('ds_state', 1)
            ->findAll();

        return $this->renderWorkPage('지정판매소 지도', 'admin/designated_shop/map', [
            'shops'              => $shops,
            'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
        ]);
    }

    /**
     * 구·군 코드 → 표시명 (코드종류 C, 플랫폼+지자체 범위).
     *
     * @return array<string, string>
     */
    private function gugunCodeNameMap(int $lgIdx): array
    {
        $ckIdx = $this->codeKindIdxByCkCode('C');
        if ($ckIdx === null) {
            return [];
        }
        $rows = model(CodeDetailModel::class)->getByKind($ckIdx, true, $lgIdx);
        $map   = [];
        foreach ($rows as $r) {
            $map[(string) $r->cd_code] = (string) $r->cd_name;
        }

        return $map;
    }

    /**
     * 효과 지자체 기준 구·군 마스터 행 (기본코드 C → 없으면 지정판매소에 실제 입력된 코드).
     *
     * @return list<array{code: string, name: string}>
     */
    private function gugunMasterRowsForLg(int $lgIdx): array
    {
        $map = $this->gugunCodeNameMap($lgIdx);
        $rows = [];
        foreach ($map as $code => $name) {
            $code = trim((string) $code);
            if ($code === '') {
                continue;
            }
            $rows[] = ['code' => $code, 'name' => trim((string) $name)];
        }
        usort($rows, static fn (array $a, array $b): int => strcmp($a['code'], $b['code']));
        if ($rows !== []) {
            return $rows;
        }
        $db = \Config\Database::connect();
        $q = $db->query(
            "SELECT DISTINCT TRIM(ds_gugun_code) AS c FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_gugun_code) != '' ORDER BY c",
            [$lgIdx]
        )->getResult();
        foreach ($q as $o) {
            $c = trim((string) ($o->c ?? ''));
            if ($c === '') {
                continue;
            }
            $rows[] = ['code' => $c, 'name' => $c];
        }

        return $rows;
    }

    /**
     * GBMS형: 마스터 구·군 순서로 행을 채우고, 집계는 designatedShopDistrictStatusRows(구군 전체)와 병합.
     *
     * @return array{rows: list<object>, total: object}
     */
    private function buildGbmsNewCancelRows(int $lgIdx, int $year): array
    {
        $byCode = [];
        foreach ($this->designatedShopDistrictStatusRows($lgIdx, $year, '', 'gugun') as $r) {
            $byCode[(string) $r->gugun_code] = $r;
        }
        $master = $this->gugunMasterRowsForLg($lgIdx);
        $out     = [];
        $seen    = [];
        foreach ($master as $m) {
            $c = $m['code'];
            $seen[$c] = true;
            $st         = $byCode[$c] ?? null;
            $out[]      = $this->makeGbmsDistrictRow($m['name'], $c, $st);
        }
        foreach ($byCode as $c => $st) {
            if ($c === '' || isset($seen[$c])) {
                continue;
            }
            $out[]      = $this->makeGbmsDistrictRow((string) $st->region_label, $c, $st);
            $seen[$c] = true;
        }
        if (isset($byCode[''])) {
            $st = $byCode[''];
            $out[] = $this->makeGbmsDistrictRow('(구·군 미입력)', '', $st);
        }
        if ($out === [] && $byCode !== []) {
            foreach ($byCode as $c => $st) {
                $out[] = $this->makeGbmsDistrictRow((string) $st->region_label, $c, $st);
            }
        }
        $sumP = $sumD = $sumC = $sumCur = 0;
        foreach ($out as $o) {
            $sumP += (int) $o->prev_end;
            $sumD += (int) $o->designated_y;
            $sumC += (int) $o->cancelled_y;
            $sumCur += (int) $o->curr_end;
        }

        return [
            'rows'  => $out,
            'total' => (object) [
                'region_label' => '계',
                'prev_end'     => $sumP,
                'designated_y' => $sumD,
                'cancelled_y'  => $sumC,
                'curr_end'     => $sumCur,
            ],
        ];
    }

    /**
     * @param object|null $st designatedShopDistrictStatusRows 한 행 또는 null
     */
    private function makeGbmsDistrictRow(string $displayLabel, string $gugunCode, ?object $st): object
    {
        return (object) [
            'region_label' => $displayLabel,
            'gugun_code'   => $gugunCode,
            'prev_end'     => $st !== null ? (int) $st->prev_end : 0,
            'designated_y' => $st !== null ? (int) $st->designated_y : 0,
            'cancelled_y'  => $st !== null ? (int) $st->cancelled_y : 0,
            'curr_end'     => $st !== null ? (int) $st->curr_end : 0,
        ];
    }

    /**
     * 연도·구군·집계 단위별 신규/취소 현황 (종전=전년말, 지정·취소=당해, 현행=금년말).
     *
     * @return list<object{region_key: string, region_label: string, prev_end: int, designated_y: int, cancelled_y: int, curr_end: int}>
     */
    private function designatedShopDistrictStatusRows(
        int $lgIdx,
        int $year,
        string $filterGugunCode,
        string $granularity
    ): array {
        $year = max(1990, min(2100, $year));
        $prevEnd = sprintf('%04d-12-31', $year - 1);
        $currEnd = sprintf('%04d-12-31', $year);
        $granularity = $granularity === 'dong' ? 'dong' : 'gugun';

        $db = \Config\Database::connect();
        $gugunMap = $this->gugunCodeNameMap($lgIdx);

        $filterGugunCode = trim($filterGugunCode);
        $bind            = [
            $prevEnd,
            $prevEnd,
            $year,
            $year,
            $currEnd,
            $currEnd,
            $lgIdx,
            $filterGugunCode,
            $filterGugunCode,
        ];

        if ($granularity === 'dong') {
            $sql = "
                SELECT
                    IFNULL(NULLIF(TRIM(ds_gugun_code), ''), '') AS gugun_key,
                    IFNULL(NULLIF(TRIM(ds_zone_code), ''), '') AS zone_key,
                    SUM(
                        CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ?
                         AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?))
                        THEN 1 ELSE 0 END
                    ) AS prev_end,
                    SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END) AS designated_y,
                    SUM(
                        CASE WHEN ds_state IN (2,3)
                         AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = ?
                        THEN 1 ELSE 0 END
                    ) AS cancelled_y,
                    SUM(
                        CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ?
                         AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?))
                        THEN 1 ELSE 0 END
                    ) AS curr_end
                FROM designated_shop
                WHERE ds_lg_idx = ?
                  AND (? = '' OR ds_gugun_code = ?)
                GROUP BY gugun_key, zone_key
                ORDER BY gugun_key, zone_key
            ";
        } else {
            $sql = "
                SELECT
                    IFNULL(NULLIF(TRIM(ds_gugun_code), ''), '') AS gugun_key,
                    SUM(
                        CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ?
                         AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?))
                        THEN 1 ELSE 0 END
                    ) AS prev_end,
                    SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END) AS designated_y,
                    SUM(
                        CASE WHEN ds_state IN (2,3)
                         AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = ?
                        THEN 1 ELSE 0 END
                    ) AS cancelled_y,
                    SUM(
                        CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ?
                         AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?))
                        THEN 1 ELSE 0 END
                    ) AS curr_end
                FROM designated_shop
                WHERE ds_lg_idx = ?
                  AND (? = '' OR ds_gugun_code = ?)
                GROUP BY gugun_key
                ORDER BY gugun_key
            ";
        }

        $raw = $db->query($sql, $bind)->getResult();
        $out = [];
        foreach ($raw as $row) {
            $gk = (string) ($row->gugun_key ?? '');
            $gn = $gk !== '' ? ($gugunMap[$gk] ?? $gk) : '(구·군 미입력)';

            if ($granularity === 'dong') {
                $zk = (string) ($row->zone_key ?? '');
                $zn = $zk !== '' ? $zk : '(구역 미입력)';
                $label = $gn . ' / ' . $zn;
                $rkey  = $gk . "\t" . $zk;
            } else {
                $label = $gn;
                $rkey  = $gk;
            }

            $prev     = (int) ($row->prev_end ?? 0);
            $curr     = (int) ($row->curr_end ?? 0);
            $des      = (int) ($row->designated_y ?? 0);
            $can      = (int) ($row->cancelled_y ?? 0);
            $zk       = $granularity === 'dong' ? (string) ($row->zone_key ?? '') : '';

            $out[] = (object) [
                'region_key'        => $rkey,
                'region_label'      => $label,
                'gugun_code'        => $gk,
                'zone_code'         => $zk,
                'prev_end'          => $prev,
                'designated_y'      => $des,
                'cancelled_y'       => $can,
                'curr_end'          => $curr,
                'delta_curr_prev'   => $curr - $prev,
                'delta_des_cancel'  => $des - $can,
            ];
        }

        return $out;
    }

    /**
     * P2-18: 지정판매소 현황 (연도별 신규/취소 + 구·군·동 집계)
     */
    public function status()
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $yearRaw = $this->request->getGet('year');
        $year     = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y');
        $currentLg = $this->lgModel->find($lgIdx);
        $fixedGugunCode = trim((string) ($currentLg->lg_code ?? ''));
        $fixedGugunLabel = trim((string) ($currentLg->lg_gugun ?? ''));
        if ($fixedGugunLabel === '') {
            $fixedGugunLabel = trim((string) ($currentLg->lg_name ?? ''));
        }

        $db = \Config\Database::connect();

        // 연도별 신규등록 건수 (ds_designated_at 기준)
        $newByYear = $db->query("
            SELECT YEAR(ds_designated_at) as yr, COUNT(*) as cnt
            FROM designated_shop
            WHERE ds_lg_idx = ? AND ds_designated_at IS NOT NULL
            GROUP BY YEAR(ds_designated_at)
            ORDER BY yr DESC
        ", [$lgIdx])->getResult();

        // 연도별 취소/비활성 건수 (ds_state != 1, ds_regdate 기준)
        $cancelByYear = $db->query("
            SELECT YEAR(ds_regdate) as yr, COUNT(*) as cnt
            FROM designated_shop
            WHERE ds_lg_idx = ? AND ds_state != 1
            GROUP BY YEAR(ds_regdate)
            ORDER BY yr DESC
        ", [$lgIdx])->getResult();

        // 전체 현황 합계
        $totalActive   = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->countAllResults(false);
        $totalInactive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state !=', 1)->countAllResults(false);

        $districtRows = $this->designatedShopDistrictStatusRows($lgIdx, $year, $fixedGugunCode, 'gugun');
        $zoneRowsRaw  = $this->designatedShopDistrictStatusRows($lgIdx, $year, $fixedGugunCode, 'dong');
        $zoneSummaryRows = [];
        foreach ($zoneRowsRaw as $zr) {
            $zoneLabel = trim((string) ($zr->zone_code ?? ''));
            if ($zoneLabel === '') {
                $zoneLabel = '(구역 미입력)';
            }
            $zoneSummaryRows[] = (object) [
                'zone_label'        => $zoneLabel,
                'prev_end'          => (int) ($zr->prev_end ?? 0),
                'designated_y'      => (int) ($zr->designated_y ?? 0),
                'cancelled_y'       => (int) ($zr->cancelled_y ?? 0),
                'curr_end'          => (int) ($zr->curr_end ?? 0),
                'delta_curr_prev'   => (int) ($zr->delta_curr_prev ?? 0),
                'delta_des_cancel'  => (int) ($zr->delta_des_cancel ?? 0),
            ];
        }
        usort($zoneSummaryRows, static function ($a, $b): int {
            $cmp = $b->curr_end <=> $a->curr_end;
            if ($cmp !== 0) {
                return $cmp;
            }

            return strcmp((string) $a->zone_label, (string) $b->zone_label);
        });
        $sumP = array_sum(array_map(static fn ($r) => $r->prev_end, $districtRows));
        $sumD = array_sum(array_map(static fn ($r) => $r->designated_y, $districtRows));
        $sumC = array_sum(array_map(static fn ($r) => $r->cancelled_y, $districtRows));
        $sumCur = array_sum(array_map(static fn ($r) => $r->curr_end, $districtRows));
        $districtTotal = (object) [
            'region_label'      => '합계',
            'gugun_code'        => '',
            'zone_code'         => '',
            'prev_end'          => $sumP,
            'designated_y'      => $sumD,
            'cancelled_y'       => $sumC,
            'curr_end'          => $sumCur,
            'delta_curr_prev'   => $sumCur - $sumP,
            'delta_des_cancel'  => $sumD - $sumC,
        ];

        $yearChoices = [];
        $yMax        = (int) date('Y');
        for ($y = $yMax; $y >= $yMax - 15; $y--) {
            $yearChoices[] = $y;
        }

        return $this->renderWorkPage('지정판매소 현황', 'admin/designated_shop/status', [
            'newByYear'      => $newByYear,
            'cancelByYear'   => $cancelByYear,
            'totalActive'    => $totalActive,
            'totalInactive'  => $totalInactive,
            'districtRows'   => $districtRows,
            'districtTotal'  => $districtTotal,
            'zoneSummaryRows'=> $zoneSummaryRows,
            'reportYear'     => $year,
            'fixedGugunLabel'=> $fixedGugunLabel,
            'yearChoices'    => $yearChoices,
        ]);
    }

    /**
     * 구·군(·동) 신규/취소 현황 CSV (status 화면과 동일 조건)
     */
    public function statusExport()
    {
        helper(['admin', 'export']);
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $yearRaw = $this->request->getGet('year');
        $year     = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y');
        $currentLg = $this->lgModel->find($lgIdx);
        $fixedGugunCode = trim((string) ($currentLg->lg_code ?? ''));

        $rows = $this->designatedShopDistrictStatusRows($lgIdx, $year, $fixedGugunCode, 'gugun');
        $sumP = $sumD = $sumC = $sumCur = 0;
        foreach ($rows as $r) {
            $sumP += $r->prev_end;
            $sumD += $r->designated_y;
            $sumC += $r->cancelled_y;
            $sumCur += $r->curr_end;
        }

        $labelCol = '군·구';
        $csvRows  = [];
        $n        = 0;
        foreach ($rows as $r) {
            ++$n;
            $curr       = (int) $r->curr_end;
            $prev       = (int) $r->prev_end;
            $pctShare   = $sumCur > 0 ? round(($curr / $sumCur) * 100, 1) : 0.0;
            $yoyPct     = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : '';
            $csvRows[] = [
                $n,
                $r->region_label,
                $r->gugun_code,
                $r->prev_end,
                $r->designated_y,
                $r->cancelled_y,
                $r->curr_end,
                $r->delta_curr_prev,
                $r->delta_des_cancel,
                $pctShare,
                $yoyPct === '' ? '' : $yoyPct,
            ];
        }
        $totYoy = $sumP > 0 ? round((($sumCur - $sumP) / $sumP) * 100, 1) : '';
        $csvRows[] = [
            '',
            '합계',
            '',
            $sumP,
            $sumD,
            $sumC,
            $sumCur,
            $sumCur - $sumP,
            $sumD - $sumC,
            100,
            $totYoy === '' ? '' : $totYoy,
        ];

        export_csv(
            '지정판매소_신규취소현황_' . $year . '_' . date('Ymd') . '.csv',
            [
                '순번',
                $labelCol,
                '구코드',
                '종전(전년도말)',
                '지정(' . $year . '년)',
                '취소(' . $year . '년)',
                '현행(금년도말)',
                '증감(현행−종전)',
                '지정−취소(' . $year . '년)',
                '현행비중(%)',
                '전년대비증감률(%)',
            ],
            $csvRows
        );
    }

    /**
     * GBMS형 지정 판매소 신규/취소 현황 (구·군은 효과 지자체 마스터 고정, 연도별 조회).
     */
    public function districtNewCancel()
    {
        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $yearRaw = $this->request->getGet('year');
        $year     = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y');
        $year     = max(1990, min(2100, $year));

        $currentLg = $this->lgModel->find($lgIdx);
        $bundle    = $this->buildGbmsNewCancelRows($lgIdx, $year);

        $yearChoices = [];
        $yMax        = (int) date('Y');
        for ($y = $yMax; $y >= $yMax - 15; $y--) {
            $yearChoices[] = $y;
        }

        return $this->renderWorkPage('지정 판매소 신규/취소 현황', 'admin/designated_shop/district_new_cancel', [
            'currentLg'     => $currentLg,
            'reportYear'    => $year,
            'yearChoices'   => $yearChoices,
            'districtRows'  => $bundle['rows'],
            'districtTotal' => $bundle['total'],
        ]);
    }

    /**
     * GBMS형 신규/취소 현황 CSV (districtNewCancel 화면과 동일 조건).
     */
    public function districtNewCancelExport()
    {
        helper(['admin', 'export']);
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(work_area_home_url())
                ->with('error', '작업할 지자체가 선택되지 않았습니다.');
        }

        $yearRaw = $this->request->getGet('year');
        $year     = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y');
        $year     = max(1990, min(2100, $year));

        $bundle = $this->buildGbmsNewCancelRows($lgIdx, $year);
        $rows   = $bundle['rows'];
        $tot    = $bundle['total'];

        $csvRows = [];
        foreach ($rows as $r) {
            $csvRows[] = [
                $r->region_label,
                $r->prev_end,
                $r->designated_y,
                $r->cancelled_y,
                $r->curr_end,
            ];
        }
        $csvRows[] = [
            $tot->region_label,
            $tot->prev_end,
            $tot->designated_y,
            $tot->cancelled_y,
            $tot->curr_end,
        ];

        export_csv(
            '지정판매소_신규취소현황_' . $year . '_' . date('Ymd') . '.csv',
            [
                '군·구',
                '종전(전년도말)',
                '지정(' . $year . '년)',
                '취소(' . $year . '년)',
                '현행(금년도말)',
            ],
            $csvRows
        );
    }

    /**
     * 기본코드 종류(B·C·D)의 code_kind PK (미사용·미등록 시 null).
     */
    private function codeKindIdxByCkCode(string $ckCode): ?int
    {
        $k = model(CodeKindModel::class)
            ->where('ck_code', $ckCode)
            ->where('ck_state', 1)
            ->first();

        return $k !== null ? (int) $k->ck_idx : null;
    }

    /**
     * 주소 문자열에 행정구역 명칭이 포함되는지(공백 무시·부분 일치).
     */
    private function addressHaystackContainsRegionName(string $haystack, string $name): bool
    {
        $name = trim($name);
        if ($name === '') {
            return false;
        }
        $h = $this->compactAddressText($haystack);
        $n = $this->compactAddressText($name);

        return $h !== '' && $n !== '' && mb_stripos($h, $n, 0, 'UTF-8') !== false;
    }

    /**
     * 동일 지자체(ds_lg_idx) 소속 판매소번호 중, 끝 3자리가 모두 숫자인 것만 모아 최댓값.
     * 목록은 끝 3자리만 표시하므로, 구·동 접두(B+C+D)와 무관하게 일련이 이어지게 한다.
     */
    private function maxDesignatedShopThreeDigitSerialForLocalGovernment(int $lgIdx): int
    {
        $rows = $this->shopModel->select('ds_shop_no')->where('ds_lg_idx', $lgIdx)->findAll();
        $max  = 0;
        foreach ($rows as $row) {
            $no = trim((string) ($row->ds_shop_no ?? ''));
            if (strlen($no) < 3) {
                continue;
            }
            $tail = substr($no, -3);
            if ($tail === '' || ! ctype_digit($tail)) {
                continue;
            }
            $n = (int) $tail;
            if ($n > $max) {
                $max = $n;
            }
        }

        return $max;
    }

    /**
     * 판매소번호: 기본코드 B + C + D(각 cd_code에서 상위 코드 접두 제거 후 이어 붙임) + 3자리 일련.
     * 주소(카카오 시·구·도로명·지번)와 code_detail만 사용하며 kr_address 등 외부 참조 테이블은 사용하지 않음.
     *
     * @return array{ok:true, shop_no:string, gugun_code:string}|array{ok:false, error:string}
     */
    private function resolveDesignatedShopNumberFromAddress(
        int $lgIdx,
        string $addrSido,
        string $addrSigungu,
        string $road,
        string $jibun,
        string $zip,
        object $lg
    ): array {
        $bCk = $this->codeKindIdxByCkCode('B');
        $cCk = $this->codeKindIdxByCkCode('C');
        $dCk = $this->codeKindIdxByCkCode('D');
        if ($bCk === null || $cCk === null || $dCk === null) {
            return [
                'ok'    => false,
                'error' => '기본코드 종류(B·C·D)가 등록되어 있지 않습니다. 시스템 관리자에게 문의하세요.',
            ];
        }

        $detailModel = model(CodeDetailModel::class);
        $bRows       = $detailModel->getByKind($bCk, true, $lgIdx);
        $cRows       = $detailModel->getByKind($cCk, true, $lgIdx);
        $dRows       = $detailModel->getByKind($dCk, true, $lgIdx);

        $sido = trim($addrSido);
        $sig  = trim($addrSigungu);
        $blob = trim($sido . ' ' . $sig . ' ' . $road . ' ' . $jibun . ' ' . $zip);
        if ($blob === '') {
            $blob = trim((string) ($lg->lg_sido ?? '') . ' ' . (string) ($lg->lg_gugun ?? ''));
        }

        $bCode = null;
        foreach ($bRows as $row) {
            $nm = trim((string) $row->cd_name);
            $cd = trim((string) $row->cd_code);
            if ($nm === '' || $cd === '') {
                continue;
            }
            if ($this->koreanRegionTokenMatches($nm, $sido, $blob)) {
                $bCode = $cd;
                break;
            }
        }
        if ($bCode === null || $bCode === '') {
            return [
                'ok'    => false,
                'error' => '주소에서 시·도 기본코드(B)를 찾을 수 없습니다. 기본코드(B)에 해당 광역단위를 등록했는지 확인해 주세요.',
            ];
        }

        $cCode = null;
        foreach ($cRows as $row) {
            $cd = trim((string) $row->cd_code);
            if ($cd === '' || ! str_starts_with($cd, $bCode)) {
                continue;
            }
            $nm = trim((string) $row->cd_name);
            if ($nm === '') {
                continue;
            }
            if ($this->koreanRegionTokenMatches($nm, $sig, $blob)) {
                $cCode = $cd;
                break;
            }
        }
        if ($cCode === null || $cCode === '') {
            return [
                'ok'    => false,
                'error' => '주소에서 구·군 기본코드(C)를 찾을 수 없습니다. 기본코드(C)를 확인하거나 주소 검색 결과(시·군·구)를 확인해 주세요.',
            ];
        }

        $dCandidates = [];
        foreach ($dRows as $row) {
            $cd = trim((string) $row->cd_code);
            if ($cd === '' || ! str_starts_with($cd, $cCode)) {
                continue;
            }
            $nm = trim((string) $row->cd_name);
            if ($nm === '') {
                continue;
            }
            $dCandidates[] = [
                'len' => mb_strlen($nm, 'UTF-8'),
                'nm'  => $nm,
                'cd'  => $cd,
            ];
        }
        usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);

        $dCode = null;
        $dName = '';
        foreach ($dCandidates as $cand) {
            if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
                $dCode = $cand['cd'];
                $dName = $cand['nm'];
                break;
            }
        }
        if ($dCode === null || $dCode === '') {
            return [
                'ok'    => false,
                'error' => '주소에서 동 기본코드(D)를 찾을 수 없습니다. 지번·도로명에 법정동명이 포함되는지, 기본코드(D)에 해당 동이 등록되어 있는지 확인해 주세요.',
            ];
        }

        $cRest = str_starts_with($cCode, $bCode) ? substr($cCode, strlen($bCode)) : $cCode;
        $dRest = str_starts_with($dCode, $cCode) ? substr($dCode, strlen($cCode)) : $dCode;
        $prefix = $bCode . $cRest . $dRest;

        // 목록 UI는 판매소번호 끝 3자리만 보여 주므로, 동일 지자체(ds_lg_idx) 안에서는
        // 구·동 접두와 무관하게 일련(마지막 3자리)이 이어지도록 한다(구형 lg_code+일련과 호환).
        $maxSerial = $this->maxDesignatedShopThreeDigitSerialForLocalGovernment($lgIdx);

        return [
            'ok'          => true,
            'shop_no'     => $prefix . sprintf('%03d', $maxSerial + 1),
            'gugun_code'  => $cCode,
            'dong_code'   => $dCode,
            'dong_name'   => $dName,
        ];
    }

    /**
     * [AJAX] 주소 선택 시, 해당 주소의 동 기본코드(D)와 동명을 반환.
     * 등록 폼에서 주소 검색 직후 「구·군(동코드)」 표시에 사용한다.
     */
    public function resolveAddressCodes()
    {
        if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
            return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '권한이 없습니다.']);
        }

        helper('admin');
        $lgIdx = admin_effective_lg_idx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return $this->response->setJSON(['ok' => false, 'error' => '작업할 지자체가 선택되지 않았습니다.']);
        }
        $lg = $this->lgModel->find($lgIdx);
        if ($lg === null) {
            return $this->response->setJSON(['ok' => false, 'error' => '지자체 정보를 찾을 수 없습니다.']);
        }

        $addrSido    = (string) $this->request->getPost('addr_search_sido');
        $addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
        $dsAddr      = (string) $this->request->getPost('ds_addr');
        $dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
        $dsZip       = (string) $this->request->getPost('ds_zip');

        $resolved = $this->resolveDesignatedShopNumberFromAddress(
            $lgIdx,
            $addrSido,
            $addrSigungu,
            $dsAddr,
            $dsAddrJibun,
            $dsZip,
            $lg
        );
        if (! $resolved['ok']) {
            return $this->response->setJSON(['ok' => false, 'error' => $resolved['error']]);
        }

        return $this->response->setJSON([
            'ok'         => true,
            'dong_code'  => $resolved['dong_code'] ?? '',
            'dong_name'  => $resolved['dong_name'] ?? '',
            'gugun_code' => $resolved['gugun_code'] ?? '',
        ]);
    }

    /**
     * designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
     */
    private function designatedShopHasDongColumn(): bool
    {
        if ($this->dongColumnExists === null) {
            $this->dongColumnExists = \Config\Database::connect()->fieldExists('ds_dong_code', 'designated_shop');
        }

        return $this->dongColumnExists;
    }

    /**
     * 여러 지정판매소의 동 기본코드(D)를 주소로 일괄 산출한다.
     * code_detail(B·C·D) 행을 한 번만 로드해 각 판매소를 매칭한다.
     *
     * @param list<object> $shops
     * @return array<int, array{code: string, name: string}>  ds_idx => [동코드, 동명]
     */
    private function resolveDongCodesForShops(int $lgIdx, array $shops): array
    {
        $out = [];
        if ($shops === []) {
            return $out;
        }

        $bCk = $this->codeKindIdxByCkCode('B');
        $cCk = $this->codeKindIdxByCkCode('C');
        $dCk = $this->codeKindIdxByCkCode('D');
        if ($bCk === null || $cCk === null || $dCk === null) {
            return $out;
        }

        $detailModel = model(CodeDetailModel::class);
        $bRows       = $detailModel->getByKind($bCk, true, $lgIdx);
        $cRows       = $detailModel->getByKind($cCk, true, $lgIdx);
        $dRows       = $detailModel->getByKind($dCk, true, $lgIdx);

        foreach ($shops as $row) {
            $idx  = (int) ($row->ds_idx ?? 0);
            $road = (string) ($row->ds_addr ?? '');
            $jibun = (string) ($row->ds_addr_jibun ?? '');
            $zip  = (string) ($row->ds_zip ?? '');
            $blob = trim($road . ' ' . $jibun . ' ' . $zip);
            if ($blob === '') {
                $out[$idx] = ['code' => '', 'name' => ''];
                continue;
            }

            // B: 시·도
            $bCode = null;
            foreach ($bRows as $r) {
                $nm = trim((string) $r->cd_name);
                $cd = trim((string) $r->cd_code);
                if ($nm === '' || $cd === '') {
                    continue;
                }
                if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
                    $bCode = $cd;
                    break;
                }
            }
            if ($bCode === null || $bCode === '') {
                $out[$idx] = ['code' => '', 'name' => ''];
                continue;
            }

            // C: 구·군
            $cCode = null;
            foreach ($cRows as $r) {
                $cd = trim((string) $r->cd_code);
                if ($cd === '' || ! str_starts_with($cd, $bCode)) {
                    continue;
                }
                $nm = trim((string) $r->cd_name);
                if ($nm === '') {
                    continue;
                }
                if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
                    $cCode = $cd;
                    break;
                }
            }
            if ($cCode === null || $cCode === '') {
                $out[$idx] = ['code' => '', 'name' => ''];
                continue;
            }

            // D: 동 — 긴 이름 우선 매칭(예: '노원동1가'가 '노원동'보다 먼저)
            $cands = [];
            foreach ($dRows as $r) {
                $cd = trim((string) $r->cd_code);
                if ($cd === '' || ! str_starts_with($cd, $cCode)) {
                    continue;
                }
                $nm = trim((string) $r->cd_name);
                if ($nm === '') {
                    continue;
                }
                $cands[] = ['len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd];
            }
            usort($cands, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);

            $dCode = '';
            $dName = '';
            foreach ($cands as $cand) {
                if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
                    $dCode = $cand['cd'];
                    $dName = $cand['nm'];
                    break;
                }
            }
            $out[$idx] = ['code' => $dCode, 'name' => $dName];
        }

        return $out;
    }

    /**
     * 산출된 동코드를 ds_dong_code 컬럼에 저장(값이 다를 때만). 컬럼이 없으면 아무것도 하지 않음.
     *
     * @param list<object> $shops
     * @param array<int, array{code: string, name: string}> $dongMap
     */
    private function persistDesignatedShopDongCodes(array $shops, array $dongMap): void
    {
        if ($dongMap === [] || ! $this->designatedShopHasDongColumn()) {
            return;
        }

        $db = \Config\Database::connect();
        foreach ($shops as $row) {
            $idx = (int) ($row->ds_idx ?? 0);
            if ($idx <= 0 || ! isset($dongMap[$idx])) {
                continue;
            }
            $code    = $dongMap[$idx]['code'];
            $current = (string) ($row->ds_dong_code ?? '');
            if ($code !== '' && $code !== $current) {
                $db->table('designated_shop')->where('ds_idx', $idx)->update(['ds_dong_code' => $code]);
                $row->ds_dong_code = $code; // 뷰 페이로드에도 반영
            }
        }
    }

    /** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
    private function kakaoJavascriptKey(): string
    {
        return (string) (config(\Config\Kakao::class)->javascriptKey ?? '');
    }
}

