#!/usr/bin/env python3
"""
Shapefile -> GDB append processor.

Reads the ZIP file uploaded via the Semakan Data Viewer and, for each
recognised shapefile found inside it, appends it to the matching GDB feature
class. Two jenis_pengesahan_data_spatial values are supported:

  - 'kawasan_kerja': feature dataset 'DBO.MODUL_SEMPADAN'
      - Kawasan_Sempadan.shp          -> DBO.SEMPADAN_KAWASAN_KERJA
      - Pusat_Ikatan_Sempadan.shp     -> DBO.PUSAT_IKATAN

  - 'inventori_hutan' (jenis_inventori 'Post-F'): feature dataset 'DBO.MODUL_INVENTORI_POST_F'
      - Baris_Tanam.shp               -> DBO.BARIS_TANAM_POST_F
      - Kawasan_Kerja.shp             -> DBO.KAWASAN_KERJA_POST_F
      - Permulaan_Garis_Tapak.shp     -> DBO.PERMULAAN_GARIS_TAPAK_POST_F
      - Petak_Inventori_Post_F.shp    -> DBO.PETAK_INVENTORI_POST_F
      - Pusat_Ikatan.shp              -> DBO.PUSAT_IKATAN_POST_F

  - 'inventori_hutan' (jenis_inventori 'Pre-F'): feature dataset 'DBO.MODUL_INVENTORI_PRE_F'
      - Baris_Tanam.shp               -> DBO.BARIS_TANAM_PRE_F
      - Kawasan_Kerja.shp             -> DBO.KAWASAN_KERJA_PRE_F
      - Permulaan_Garis_Tapak.shp     -> DBO.PERMULAAN_GARIS_TAPAK_PRE_F
      - Petak_Inventori_Pre_F.shp     -> DBO.PETAK_INVENTORI_PRE_F
      - Pusat_Ikatan.shp              -> DBO.PUSAT_IKATAN_PRE_F

  - 'tanda_pokok': feature dataset 'DBO.MODUL_TANDA_POKOK'
      - Baris_Tanam.shp               -> DBO.BARIS_TANAM_TP
      - Garisan_Kawalan_Dua.shp       -> DBO.GARISAN_KAWALAN_DUA_TP
      - Garisan_Kawalan_Satu.shp      -> DBO.GARISAN_KAWALAN_SATU_TP
      - Kawasan_Kerja.shp             -> DBO.KAWASAN_KERJA_TP
      - Permulaan_Garis_Tapak.shp     -> DBO.PERMULAAN_GARIS_TAPAK_TP
      - Pokok_Ibu.shp                 -> DBO.POKOK_IBU
      - Pokok_Perlindungan.shp        -> DBO.POKOK_PERLINDUNGAN
      - Pokok_Tebangan.shp            -> DBO.POKOK_TEBANGAN
      - Pusat_Ikatan.shp              -> DBO.PUSAT_IKATAN_TP

    - 'kawasan_lesen': feature dataset 'DBO.MODUL_LESEN'
      - Pusat_Ikatan.shp              -> DBO.PUSAT_IKATAN_LESEN
      - Kawasan_Lesen.shp             -> DBO.KAWASAN_LESEN

  - 'silvikultur' (jenis_kawasan 'Tanaman Mengaya'): feature dataset 'DBO.MODUL_RAWATAN_SILVIKULTUR_TANAMAN_MENGAYA'
      - Baris_Tanam.shp               -> DBO.BARIS_TANAM_TM
      - Blok_Kerja.shp                -> DBO.BLOK_KERJA_TM
      - Lubang_Tanaman.shp            -> DBO.LUBANG_TANAMAN_TM
      - Permulaan_Garis_Tapak.shp     -> DBO.PERMULAAN_GARIS_TAPAK_TM

  - 'silvikultur' (jenis_kawasan 'Potong Akar'): feature dataset 'DBO.MODUL_RAWATAN_SILVIKULTUR_POTONG_AKAR'
      - Baris_Tanam.shp               -> DBO.BARIS_TANAM_PA
      - Blok_Kerja.shp                -> DBO.BLOK_KERJA_PA
      - Lubang_Tanaman.shp            -> DBO.LUBANG_TANAMAN_PA
      - Permulaan_Garis_Tapak.shp     -> DBO.PERMULAAN_GARIS_TAPAK_PA

Each shapefile is first projected to Kertau (EPSG:4245) to avoid the
geometry shifts that can occur when appending features whose spatial
reference doesn't match the target feature class.

Before appending, every feature's Tahun / NO_REF / NEGERI attributes are
overwritten with the values from the maklumat_apis record (fetched via the
Laravel API), rather than whatever values are already in the shapefile.

Progress/result is reported back to the Laravel API
(update_conversion_status) so it can be shown on the viewer page:
  - process_status, error_message, conversion_started_at,
    conversion_complete_at, total_features_added

Usage:
    python processing.py <record_id> <zip_path> [--api-base-url URL] [--gdb PATH]

Triggered by: SemakanDataController@proses via script/run_process.bat
"""

import argparse
import datetime
import json
import logging
import os
import re
import shutil
import sys
import tempfile
import time
import zipfile

import arcpy

# Platform-specific imports for file locking
if os.name == 'nt':  # Windows
    import msvcrt
else:  # Unix/Linux
    import fcntl

try:
    import requests
except ImportError:  # arcgispro-py3 ships with requests, but fall back just in case
    requests = None
    from urllib import request as urllib_request

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_API_BASE_URL = "http://localhost:8000/api"
DEFAULT_GDB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "JPSM_EGP.sde")

SUPPORTED_JENIS = ("kawasan_kerja", "inventori_hutan", "tanda_pokok", "kawasan_lesen", "silvikultur")

# Per jenis_pengesahan_data_spatial (and, for 'inventori_hutan', per
# jenis_inventori) processing profile:
#   feature_dataset             -> feature dataset inside the GDB that holds
#                                  the target feature classes
#   shapefile_to_feature_class  -> maps the shapefile base name (without
#                                  extension, lowercased) found inside the ZIP
#                                  to the target feature class name
#
# 'kawasan_kerja' has no jenis_inventori distinction, so it's keyed under None.
PROCESS_PROFILES = {
    "kawasan_kerja": {
        None: {
            "feature_dataset": "DBO.MODUL_SEMPADAN",
            "shapefile_to_feature_class": {
                "kawasan_sempadan": "DBO.SEMPADAN_KAWASAN_KERJA",
                "pusat_ikatan_sempadan": "DBO.PUSAT_IKATAN",
            },
        },
    },
    "kawasan_lesen": {
        None: {
            "feature_dataset": "DBO.MODUL_LESEN",
            "shapefile_to_feature_class": {
                "pusat_ikatan": "DBO.PUSAT_IKATAN_LESEN",
                "kawasan_lesen": "DBO.KAWASAN_LESEN",
            },
        },
    },
    "inventori_hutan": {
        "Post-F": {
            "feature_dataset": "DBO.MODUL_INVENTORI_POST_F",
            "shapefile_to_feature_class": {
                "baris_tanam": "DBO.BARIS_TANAM_POST_F",
                "kawasan_kerja": "DBO.KAWASAN_KERJA_POST_F",
                "permulaan_garis_tapak": "DBO.PERMULAAN_GARIS_TAPAK_POST_F",
                "petak_inventori_post_f": "DBO.PETAK_INVENTORI_POST_F",
                "pusat_ikatan": "DBO.PUSAT_IKATAN_POST_F",
            },
        },
        "Pre-F": {
            "feature_dataset": "DBO.MODUL_INVENTORI_PRE_F",
            "shapefile_to_feature_class": {
                "baris_tanam": "DBO.BARIS_TANAM_PRE_F",
                "kawasan_kerja": "DBO.KAWASAN_KERJA_PRE_F",
                "permulaan_garis_tapak": "DBO.PERMULAAN_GARIS_TAPAK_PRE_F",
                "petak_inventori_pre_f": "DBO.PETAK_INVENTORI_PRE_F",
                "pusat_ikatan": "DBO.PUSAT_IKATAN_PRE_F",
            },
        },
    },
    "tanda_pokok": {
        None: {
            "feature_dataset": "DBO.MODUL_TANDA_POKOK",
            "shapefile_to_feature_class": {
                "baris_tanam": "DBO.BARIS_TANAM_TP",
                "garisan_kawalan_dua": "DBO.GARISAN_KAWALAN_DUA_TP",
                "garisan_kawalan_satu": "DBO.GARISAN_KAWALAN_SATU_TP",
                "kawasan_kerja": "DBO.KAWASAN_KERJA_TP",
                "permulaan_garis_tapak": "DBO.PERMULAAN_GARIS_TAPAK_TP",
                "pokok_ibu": "DBO.POKOK_IBU",
                "pokok_perlindungan": "DBO.POKOK_PERLINDUNGAN",
                "pokok_tebangan": "DBO.POKOK_TEBANGAN",
                "pusat_ikatan": "DBO.PUSAT_IKATAN_TP",
            },
        },
    },
    "silvikultur": {
            "Tanaman Mengaya": {
                "feature_dataset": "DBO.MODUL_RAWATAN_SILVIKULTUR_TANAMAN_MENGAYA",
                "shapefile_to_feature_class": {
                    "baris_tanam": "DBO.BARIS_TANAM_TM",
                    "blok_kerja": "DBO.BLOK_KERJA_TM",
                    "lubang_tanaman": "DBO.LUBANG_TANAMAN_TM",
                    "permulaan_garis_tapak": "DBO.PERMULAAN_GARIS_TAPAK_TM",
                },
            },
            "Potong Akar": {
                "feature_dataset": "DBO.MODUL_RAWATAN_SILVIKULTUR_POTONG_AKAR",
                "shapefile_to_feature_class": {
                    "baris_tanam": "DBO.BARIS_TANAM_PA",
                    "blok_kerja": "DBO.BLOK_KERJA_PA",
                    "lubang_tanaman": "DBO.LUBANG_TANAMAN_PA",
                    "permulaan_garis_tapak": "DBO.PERMULAAN_GARIS_TAPAK_PA",
                },
            },
        },
}

# Attribute fields that must be stamped on every feature before appending,
# sourced from the maklumat_apis record instead of the shapefile's own values.
ATTRIBUTE_FIELD_MAP = {
    "TAHUN": "tahun",
    "NO_REF": "no_rujukan",
    "NEGERI": "negeri",
    "KOD_NEGERI": "kod_negeri",
}

# Schema used when a shapefile is missing one of the attribute fields above;
# matches the target feature class's field definitions (esriFieldTypeString,
# length 254, nullable).
MISSING_FIELD_TYPE = "TEXT"
MISSING_FIELD_LENGTH = 254

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logger = logging.getLogger("kawasan_kerja_processor")
logger.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
logger.addHandler(_handler)


def sanitize_error_message(message):
    """Strip local file paths from error messages before they are stored/displayed."""
    message = str(message)
    message = re.sub(r"[A-Za-z]:\\[^\s,;]+", "[FILE_PATH]", message)
    message = re.sub(r"(?<![:\w])/[^\s,;]+/[^\s,;]+", "[FILE_PATH]", message)
    return message


def now_iso():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


# ---------------------------------------------------------------------------
# GDB lock manager
# ---------------------------------------------------------------------------
# Concurrent arcpy Append operations against the same geodatabase fail with
# "Cannot acquire a lock", so only one process/thread may touch the target
# GDB at a time. This mirrors the SDELockManager pattern used in
# mpbp_geoconverter/scripts/dwg_to_shapefile_arcpy.py.
class GDBLockManager:
    """Manage a file-based lock so only one process appends to the GDB at a time."""

    def __init__(self, lock_file_path=None, timeout=600, retry_delay=2):
        if lock_file_path is None:
            lock_file_path = os.path.join(tempfile.gettempdir(), 'kawasan_kerja_gdb.lock')
        self.lock_file_path = lock_file_path
        self.timeout = timeout
        self.retry_delay = retry_delay
        self.lock_file = None
        self.acquired = False

    def acquire(self):
        """Acquire the lock with retry logic."""
        start_time = time.time()
        attempt = 0

        while time.time() - start_time < self.timeout:
            attempt += 1
            try:
                self.lock_file = open(self.lock_file_path, 'w')

                if os.name == 'nt':  # Windows
                    msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_NBLCK, 1)
                else:  # Unix/Linux
                    fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

                self.lock_file.write(f"Locked at: {datetime.datetime.now()}\n")
                self.lock_file.write(f"Process ID: {os.getpid()}\n")
                self.lock_file.flush()

                self.acquired = True
                logger.info(f"GDB lock acquired (attempt {attempt})")
                return True

            except (IOError, OSError):
                if attempt == 1:
                    logger.info("GDB lock is held by another process, waiting...")
                elif attempt % 10 == 0:
                    elapsed = int(time.time() - start_time)
                    logger.info(f"Still waiting for GDB lock... ({elapsed}s elapsed)")

                if self.lock_file:
                    try:
                        self.lock_file.close()
                    except Exception:
                        pass
                    self.lock_file = None

                time.sleep(self.retry_delay)

        logger.error(f"Failed to acquire GDB lock after {self.timeout}s")
        return False

    def release(self):
        """Release the lock."""
        if self.acquired and self.lock_file:
            try:
                if os.name == 'nt':  # Windows
                    msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_UNLCK, 1)
                else:  # Unix/Linux
                    fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)

                self.lock_file.close()
                self.acquired = False
                logger.info("GDB lock released")

            except Exception as e:
                logger.warning(f"Error releasing GDB lock: {str(e)}")
            finally:
                self.lock_file = None

    def __enter__(self):
        if self.acquire():
            return self
        raise TimeoutError(f"Failed to acquire GDB lock within {self.timeout} seconds")

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.release()
        return False


# ---------------------------------------------------------------------------
# API client
# ---------------------------------------------------------------------------
class ApiClient:
    """Talks to the Laravel /api/semakan_data endpoints (unauthenticated, no CSRF)."""

    def __init__(self, base_url):
        self.base_url = base_url.rstrip("/")

    def _post(self, path, payload):
        url = f"{self.base_url}{path}"

        if requests is not None:
            response = requests.post(url, json=payload, timeout=30)
            response.raise_for_status()
            return response.json()

        # Fallback using urllib when the `requests` package isn't available
        data = json.dumps(payload).encode("utf-8")
        req = urllib_request.Request(
            url,
            data=data,
            method="POST",
            headers={"Content-Type": "application/json", "Accept": "application/json"},
        )
        with urllib_request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))

    def get_maklumat_api(self, record_id):
        """POST /semakan_data/{id} -> returns the maklumat_apis record."""
        return self._post(f"/semakan_data/{record_id}", {})

    def update_conversion_status(self, record_id, **fields):
        """POST /semakan_data/update_conversion_status/{id}"""
        return self._post(f"/semakan_data/update_conversion_status/{record_id}", fields)


# ---------------------------------------------------------------------------
# Core processing
# ---------------------------------------------------------------------------
def extract_zip(zip_path, extract_dir):
    logger.info("Extracting ZIP file...")
    with zipfile.ZipFile(zip_path, "r") as zf:
        zf.extractall(extract_dir)
    logger.info(f"Extracted to temporary directory")


def find_shapefiles(extract_dir, shapefile_to_feature_class):
    """Recursively find shapefiles matching the given shapefile-to-feature-class map."""
    found = {}
    for root, _dirs, files in os.walk(extract_dir):
        for filename in files:
            if not filename.lower().endswith(".shp"):
                continue
            base_name = os.path.splitext(filename)[0]
            key = base_name.lower()
            if key in shapefile_to_feature_class:
                found[key] = os.path.join(root, filename)
    return found


def resolve_target_feature_class(gdb_path, feature_dataset, feature_class_name):
    """Resolve the target feature class path, tolerating space/underscore naming differences."""
    candidates = [
        feature_class_name,
        feature_class_name.replace(" ", "_"),
        feature_class_name.replace("_", " "),
    ]
    for candidate in candidates:
        candidate_path = os.path.join(gdb_path, feature_dataset, candidate)
        if arcpy.Exists(candidate_path):
            return candidate_path
    raise RuntimeError(f"Feature class '{feature_class_name}' not found in SDE.")


def reproject_to_kertau(shapefile_path, output_dir):
    """Project a shapefile to Kertau (EPSG:4245) before appending.

    Appending features whose spatial reference doesn't exactly match the
    target feature class can silently shift geometry (arcpy.management.Append
    does not always apply the correct geographic transformation), so every
    shapefile is normalised to Kertau first.
    """
    target_sr = arcpy.SpatialReference(4245)
    source_sr = arcpy.Describe(shapefile_path).spatialReference

    if source_sr is None or source_sr.factoryCode == 0:
        logger.warning(
            f"{os.path.basename(shapefile_path)} has no defined spatial reference; "
            "skipping reprojection to Kertau."
        )
        return shapefile_path

    if source_sr.factoryCode == target_sr.factoryCode:
        logger.info(f"{os.path.basename(shapefile_path)} is already Kertau; skipping reprojection.")
        return shapefile_path

    base_name = os.path.splitext(os.path.basename(shapefile_path))[0]
    projected_path = os.path.join(output_dir, f"{base_name}_kertau.shp")

    logger.info(
        f"Reprojecting {os.path.basename(shapefile_path)} from '{source_sr.name}' to 'Kertau'..."
    )
    arcpy.management.Project(shapefile_path, projected_path, target_sr)

    return projected_path


def stamp_attributes(shapefile_path, attribute_values):
    """Overwrite Tahun / NO_REF / NEGERI / KOD_NEGERI on every feature with values from the API.

    Any of these fields missing from the shapefile are added first (as TEXT
    fields, length 254, nullable - matching the target feature class schema)
    so the shapefile's schema lines up with the GDB feature class before
    arcpy.management.Append runs.
    """
    existing_field_names = {f.name.lower(): f.name for f in arcpy.ListFields(shapefile_path)}

    fields_to_update = []
    values_to_set = []
    for shp_field, value in attribute_values.items():
        actual_name = existing_field_names.get(shp_field.lower())
        if not actual_name:
            logger.info(f"Field '{shp_field}' not found in {os.path.basename(shapefile_path)}; adding it.")
            arcpy.management.AddField(
                shapefile_path,
                shp_field,
                field_type=MISSING_FIELD_TYPE,
                field_length=MISSING_FIELD_LENGTH,
                field_alias=shp_field,
                field_is_nullable="NULLABLE",
            )
            actual_name = shp_field
        fields_to_update.append(actual_name)
        values_to_set.append(value)

    if not fields_to_update:
        return

    with arcpy.da.UpdateCursor(shapefile_path, fields_to_update) as cursor:
        for row in cursor:
            for i in range(len(values_to_set)):
                row[i] = values_to_set[i]
            cursor.updateRow(row)

    logger.info(f"Stamped fields {fields_to_update} on {os.path.basename(shapefile_path)}")


def _sql_quote(value):
    """Quote/escape a value for use in an arcpy SQL where_clause."""
    if isinstance(value, str):
        return "'{}'".format(value.replace("'", "''"))
    return str(value)


def _delete_features_by_no_ref(target_fc_path, no_ref_value):
    """Delete rows in target_fc_path whose NO_REF matches no_ref_value.

    Must be called while an arcpy.da.Editor edit session/operation is active;
    deleting rows in this SDE feature class outside of one fails with:
    "Objects in this class cannot be updated outside an edit session".
    """
    if no_ref_value is None:
        return 0

    existing_field_names = {f.name.lower(): f.name for f in arcpy.ListFields(target_fc_path)}
    no_ref_field = existing_field_names.get("no_ref")
    if not no_ref_field:
        logger.warning(f"Field 'NO_REF' not found in {target_fc_path}, skipping delete step.")
        return 0

    where_clause = f"{arcpy.AddFieldDelimiters(target_fc_path, no_ref_field)} = {_sql_quote(no_ref_value)}"

    deleted_count = 0
    with arcpy.da.UpdateCursor(target_fc_path, [no_ref_field], where_clause) as cursor:
        for _row in cursor:
            cursor.deleteRow()
            deleted_count += 1

    if deleted_count:
        logger.info(
            f"Deleted {deleted_count} existing feature(s) with NO_REF={no_ref_value!r} from {target_fc_path}"
        )

    return deleted_count


def delete_and_append_features(gdb_path, shapefile_path, target_fc_path, no_ref_value, max_retries=3, retry_delay=5):
    """Delete existing features sharing the same NO_REF, then append the new ones.

    Both operations run inside a single arcpy.da.Editor edit session/operation
    (rather than two separate sessions) because arcpy.management.Append also
    requires an active edit session against this feature class - running it
    outside one raises the same
    "Objects in this class cannot be updated outside an edit session" error
    that the standalone delete would.

    multiuser_mode must match whether the feature class is actually versioned:
    True for versioned data, False for non-versioned data (even if it has
    change tracking/archiving enabled). Passing the wrong value raises
    "TypeError: cannot update the table".
    """
    feature_count = int(arcpy.management.GetCount(shapefile_path)[0])
    if feature_count == 0:
        logger.warning(f"Skipping empty shapefile: {os.path.basename(shapefile_path)}")
        return 0

    is_versioned = bool(arcpy.Describe(target_fc_path).isVersioned)

    last_error = None
    for attempt in range(1, max_retries + 1):
        editor = arcpy.da.Editor(gdb_path)
        try:
            editor.startEditing(False, is_versioned)
            editor.startOperation()
            try:
                _delete_features_by_no_ref(target_fc_path, no_ref_value)

                arcpy.management.Append(
                    inputs=shapefile_path,
                    target=target_fc_path,
                    schema_type="NO_TEST",
                )

                editor.stopOperation()
                editor.stopEditing(True)
            except Exception:
                editor.abortOperation()
                editor.stopEditing(False)
                raise

            logger.info(
                f"Appended {feature_count} feature(s) from {os.path.basename(shapefile_path)} -> {target_fc_path}"
            )
            return feature_count

        except arcpy.ExecuteError:
            last_error = arcpy.GetMessages(2)

            if "000224" not in last_error and "lock" not in last_error.lower():
                raise RuntimeError(last_error)

            logger.warning(
                f"Attempt {attempt}/{max_retries}: GDB lock busy ({last_error}). "
                f"Clearing workspace cache and retrying in {retry_delay}s..."
            )

            # This is the documented arcpy fix for ERROR 000224: arcpy itself
            # can hold a schema lock from earlier Describe/ListFields/Exists
            # calls in this same process; clearing the cache releases it.
            try:
                arcpy.management.ClearWorkspaceCache(target_fc_path)
            except Exception:
                pass

            if attempt < max_retries:
                time.sleep(retry_delay)

    raise RuntimeError(
        "Geodatabase dikunci oleh proses/aplikasi lain (cth. ArcGIS Pro/ArcCatalog sedang membuka "
        f"geodatabase ini). Sila tutup semua aplikasi yang menggunakan GDB tersebut dan cuba lagi. "
        f"Ralat asal: {last_error}"
    )


def resolve_process_profile(record):
    """Pick the feature dataset + shapefile map to use for this record.

    'kawasan_kerja' has a single profile. 'inventori_hutan' and 'silvikultur'
    each have multiple, selected by the record's jenis_inventori
    ('Post-F' / 'Pre-F') or jenis_kawasan ('Tanaman Mengaya' / 'Potong Akar')
    respectively.
    """
    jenis = record.get("jenis_pengesahan_data_spatial")
    jenis_profiles = PROCESS_PROFILES.get(jenis)
    if jenis_profiles is None:
        raise ValueError(
            "Proses ini hanya menyokong jenis_pengesahan_data_spatial = "
            f"{SUPPORTED_JENIS} (dijumpai: '{jenis}')."
        )

    if jenis == "inventori_hutan":
        jenis_inventori = record.get("jenis_inventori")
        profile = jenis_profiles.get(jenis_inventori)
        if profile is None:
            raise ValueError(
                "Rekod 'inventori_hutan' mesti mempunyai jenis_inventori 'Post-F' atau 'Pre-F' "
                f"(dijumpai: '{jenis_inventori}')."
            )
    elif jenis == "silvikultur":
        jenis_kawasan = record.get("jenis_kawasan")
        profile = jenis_profiles.get(jenis_kawasan)
        if profile is None:
            raise ValueError(
                "Rekod 'silvikultur' mesti mempunyai jenis_kawasan 'Tanaman Mengaya' atau 'Potong Akar' "
                f"(dijumpai: '{jenis_kawasan}')."
            )
    else:
        profile = jenis_profiles[None]

    return profile


def process(record_id, zip_path, api, gdb_path):
    record = api.get_maklumat_api(record_id)
    profile = resolve_process_profile(record)
    feature_dataset = profile["feature_dataset"]
    shapefile_to_feature_class = profile["shapefile_to_feature_class"]

    attribute_values = {
        shp_field: record.get(api_field)
        for shp_field, api_field in ATTRIBUTE_FIELD_MAP.items()
    }

    if not os.path.exists(zip_path):
        raise FileNotFoundError("Fail ZIP tidak dijumpai.")

    if not arcpy.Exists(gdb_path):
        raise RuntimeError("Geodatabase sasaran tidak dijumpai.")

    arcpy.env.overwriteOutput = True

    extract_dir = tempfile.mkdtemp(prefix=f"semakan_data_{record_id}_")
    total_appended = 0
    skipped_feature_classes = []
    try:
        extract_zip(zip_path, extract_dir)
        shapefiles = find_shapefiles(extract_dir, shapefile_to_feature_class)

        if not shapefiles:
            expected_names = ", ".join(sorted(shapefile_to_feature_class.keys()))
            raise RuntimeError(
                f"Tiada shapefile yang dikenali dijumpai dalam fail ZIP. Dijangka salah satu: {expected_names}."
            )

        # Only one process may append to the shared GDB at a time.
        try:
            with GDBLockManager(timeout=600, retry_delay=2):
                # Release any schema locks arcpy itself may still be holding
                # on this workspace from a previous run in the same environment.
                try:
                    arcpy.management.ClearWorkspaceCache(gdb_path)
                except Exception:
                    pass

                for key, shapefile_path in shapefiles.items():
                    feature_class_name = shapefile_to_feature_class[key]
                    logger.info(f"Processing {os.path.basename(shapefile_path)} -> {feature_class_name}")

                    shapefile_path = reproject_to_kertau(shapefile_path, extract_dir)
                    target_fc_path = resolve_target_feature_class(gdb_path, feature_dataset, feature_class_name)
                    stamp_attributes(shapefile_path, attribute_values)
                    appended_count = delete_and_append_features(
                        gdb_path, shapefile_path, target_fc_path, attribute_values.get("NO_REF")
                    )
                    if appended_count == 0:
                        skipped_feature_classes.append(feature_class_name)
                    total_appended += appended_count
        except TimeoutError:
            raise RuntimeError("Geodatabase sedang digunakan oleh proses lain. Sila cuba lagi sebentar.")

        return total_appended, skipped_feature_classes
    finally:
        shutil.rmtree(extract_dir, ignore_errors=True)


def main():
    parser = argparse.ArgumentParser(description="Process a semakan data shapefile ZIP into GDB.")
    parser.add_argument("record_id", help="maklumat_apis record id")
    parser.add_argument("zip_path", help="Path to the uploaded ZIP file")
    parser.add_argument("--api-base-url", default=DEFAULT_API_BASE_URL)
    parser.add_argument("--gdb", default=DEFAULT_GDB_PATH)
    args = parser.parse_args()

    api = ApiClient(args.api_base_url)

    try:
        logger.info(f"Starting processing for record #{args.record_id}")

        api.update_conversion_status(
            args.record_id,
            process_status="Sedang Diproses",
            conversion_started_at=now_iso(),
            conversion_complete_at=None,
            error_message=None,
            total_features_added=None,
        )

        total_appended, skipped_feature_classes = process(args.record_id, args.zip_path, api, args.gdb)

        skip_note = None
        if skipped_feature_classes:
            skip_note = (
                "Berjaya diproses, tetapi shapefile berikut diabaikan kerana tiada ciri (kosong): "
                + ", ".join(skipped_feature_classes)
            )
            logger.warning(skip_note)

        api.update_conversion_status(
            args.record_id,
            process_status="Selesai",
            conversion_complete_at=now_iso(),
            error_message=skip_note,
            total_features_added=total_appended,
        )
        logger.info(f"Completed. Total features appended: {total_appended}")

    except Exception as exc:
        error_message = sanitize_error_message(str(exc))
        logger.error(f"Processing failed: {error_message}")
        try:
            api.update_conversion_status(
                args.record_id,
                process_status="Gagal",
                conversion_complete_at=now_iso(),
                error_message=error_message,
            )
        except Exception as api_exc:
            logger.error(f"Failed to report failure status to API: {api_exc}")
        sys.exit(1)


if __name__ == "__main__":
    main()
