const express = require("express");
const crypto = require("crypto");
const db = require("../db/polygons");
const { getBBox } = require("../utils/bbox");

const router = express.Router();
const LOCK_TTL_MS = 15 * 60 * 1000;

router.get("/", (_req, res) => {
    const rows = db.prepare(`
        SELECT
            s.*,
            st.status,
            st.head_commit_id,
            st.version,
            st.locked_by,
            st.locked_at,
            st.lock_expires_at
        FROM sections s
        LEFT JOIN section_states st
          ON st.section_id = s.id
        ORDER BY s.updated_at DESC
    `).all();

    res.json(rows.map(normalizeSectionRow));
});

router.post("/", (req, res) => {
    const title = normalizeRequiredString(req.body?.title);
    if (!title) {
        return res.status(400).json({ error: "title is required" });
    }

    const id = normalizeId(req.body?.id) || crypto.randomUUID();
    const now = new Date().toISOString();
    const description = normalizeOptionalString(req.body?.description);
    const userId = normalizeActor(req.body?.user_id || req.body?.created_by);
    const createdBy = normalizeActor(req.body?.created_by || req.body?.user_id);

    try {
        const tx = db.transaction(() => {
            db.prepare(`
                INSERT INTO sections (id, title, description, user_id, created_by, created_at, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            `).run(id, title, description, userId, createdBy, now, now);

            ensureSectionState(id, now);
        });
        tx();
    } catch (err) {
        if (isSqliteConstraint(err)) {
            return res.status(409).json({ error: "Section id already exists" });
        }
        console.error("Create section failed", err);
        return res.status(500).json({ error: "Create section failed" });
    }

    res.status(201).json(getSectionById(id));
});

router.get("/:sectionId/editor", (req, res) => {
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const actor = normalizeActor(req.query.user_id || req.query.user || req.get("x-user-id"));
    const now = new Date().toISOString();
    let state;
    let commit = null;

    try {
        const tx = db.transaction(() => {
            state = ensureSectionState(section.id, now);
            assertLockAvailable(state, actor, now);
            if (actor) {
                state = acquireLock(section.id, actor, now);
            }
            if (state.head_commit_id) {
                commit = getCommitForSection(section.id, state.head_commit_id);
            }
        });
        tx();
    } catch (err) {
        if (err.status) {
            return res.status(err.status).json({ error: err.message });
        }
        console.error("Open editor failed", err);
        return res.status(500).json({ error: "Open editor failed" });
    }

    res.json({
        section,
        state: normalizeStateRow(state),
        commit: commit ? normalizeCommitRow(commit, false) : null,
        snapshot: commit ? parseSnapshotJson(commit.snapshot_json) : buildEmptySnapshot(section),
    });
});

router.post("/:sectionId/lock", (req, res) => {
    const actor = normalizeActor(req.body?.user_id || req.body?.user || req.get("x-user-id"));
    if (!actor) {
        return res.status(400).json({ error: "user_id is required" });
    }

    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const now = new Date().toISOString();
    try {
        const state = db.transaction(() => {
            const current = ensureSectionState(section.id, now);
            assertLockAvailable(current, actor, now);
            return acquireLock(section.id, actor, now);
        })();
        res.json({ state: normalizeStateRow(state) });
    } catch (err) {
        if (err.status) {
            return res.status(err.status).json({ error: err.message });
        }
        console.error("Lock section failed", err);
        res.status(500).json({ error: "Lock section failed" });
    }
});

router.post("/:sectionId/unlock", (req, res) => {
    const actor = normalizeActor(req.body?.user_id || req.body?.user || req.get("x-user-id"));
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const state = ensureSectionState(section.id, new Date().toISOString());
    if (state.locked_by && actor && state.locked_by !== actor) {
        return res.status(409).json({ error: "Section is locked by another user" });
    }

    db.prepare(`
        UPDATE section_states
        SET locked_by = NULL,
            locked_at = NULL,
            lock_expires_at = NULL,
            updated_at = ?
        WHERE section_id = ?
    `).run(new Date().toISOString(), section.id);

    res.json({ success: true });
});

router.get("/:sectionId/commits", (req, res) => {
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const includeSnapshot = req.query.include_snapshot === "1" || req.query.include_snapshot === "true";
    const rows = db.prepare(`
        SELECT *
        FROM section_commits
        WHERE section_id = ?
        ORDER BY commit_no DESC
    `).all(section.id);

    res.json(rows.map((row) => normalizeCommitRow(row, includeSnapshot)));
});

router.post("/:sectionId/commits", (req, res) => {
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const actor = normalizeActor(req.body?.created_by || req.body?.user_id || req.get("x-user-id"));
    if (!actor) {
        return res.status(400).json({ error: "created_by is required" });
    }

    const snapshotResult = normalizeSnapshotInput(req.body?.snapshot_json ?? req.body?.snapshot);
    if (snapshotResult.error) {
        return res.status(400).json({ error: snapshotResult.error });
    }

    try {
        const result = db.transaction(() => {
            const now = new Date().toISOString();
            const state = ensureSectionState(section.id, now);
            assertCanEdit(state, actor, now);
            assertExpectedState(state, req.body);
            validateSnapshot(snapshotResult.snapshot);

            const commit = insertCommit({
                section,
                state,
                snapshotJson: snapshotResult.snapshotJson,
                snapshotHash: hashString(snapshotResult.snapshotJson),
                kind: "manual",
                restoredFromCommitId: null,
                actor,
                title: normalizeOptionalString(req.body?.title),
                note: normalizeOptionalString(req.body?.note),
                now,
            });

            const nextState = setHeadCommit(section.id, commit.id, now);
            return { commit, state: nextState };
        })();

        res.status(201).json({
            commit: normalizeCommitRow(result.commit, true),
            state: normalizeStateRow(result.state),
        });
    } catch (err) {
        handleRouteError(res, err, "Create commit failed");
    }
});

router.post("/:sectionId/restore", (req, res) => {
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const actor = normalizeActor(req.body?.created_by || req.body?.user_id || req.get("x-user-id"));
    const restoreCommitId = normalizeId(req.body?.commit_id || req.body?.restore_commit_id);
    if (!actor) {
        return res.status(400).json({ error: "created_by is required" });
    }
    if (!restoreCommitId) {
        return res.status(400).json({ error: "commit_id is required" });
    }

    try {
        const result = db.transaction(() => {
            const now = new Date().toISOString();
            const state = ensureSectionState(section.id, now);
            assertCanEdit(state, actor, now);
            assertExpectedState(state, req.body);

            const sourceCommit = getCommitForSection(section.id, restoreCommitId);
            if (!sourceCommit) {
                throw createHttpError("Commit not found", 404);
            }

            const commit = insertCommit({
                section,
                state,
                snapshotJson: sourceCommit.snapshot_json,
                snapshotHash: sourceCommit.snapshot_hash || hashString(sourceCommit.snapshot_json),
                kind: "restore",
                restoredFromCommitId: sourceCommit.id,
                actor,
                title: normalizeOptionalString(req.body?.title) || `Restore #${sourceCommit.commit_no}`,
                note: normalizeOptionalString(req.body?.note),
                now,
            });

            const nextState = setHeadCommit(section.id, commit.id, now);
            return { commit, state: nextState };
        })();

        res.status(201).json({
            commit: normalizeCommitRow(result.commit, true),
            state: normalizeStateRow(result.state),
        });
    } catch (err) {
        handleRouteError(res, err, "Restore section failed");
    }
});

router.post("/:sectionId/submit", (req, res) => {
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const actor = normalizeActor(req.body?.submitted_by || req.body?.user_id || req.get("x-user-id"));
    if (!actor) {
        return res.status(400).json({ error: "submitted_by is required" });
    }

    try {
        const submission = db.transaction(() => {
            const now = new Date().toISOString();
            const state = ensureSectionState(section.id, now);
            assertLockAvailable(state, actor, now);
            if (state.status !== "editing") {
                throw createHttpError("Section is not editable", 409);
            }

            const commitId = state.head_commit_id;
            if (!commitId) {
                throw createHttpError("Section has no head commit to submit", 400);
            }

            const commit = getCommitForSection(section.id, commitId);
            if (!commit) {
                throw createHttpError("Commit not found", 404);
            }

            const id = crypto.randomUUID();
            db.prepare(`
                INSERT INTO section_submissions (
                    id, section_id, commit_id, submitted_by, submitted_at, status,
                    snapshot_json, snapshot_hash
                )
                VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
            `).run(
                id,
                section.id,
                commit.id,
                actor,
                now,
                commit.snapshot_json,
                commit.snapshot_hash || hashString(commit.snapshot_json)
            );

            db.prepare(`
                UPDATE section_states
                SET status = 'submitted',
                    locked_by = NULL,
                    locked_at = NULL,
                    lock_expires_at = NULL,
                    updated_at = ?
                WHERE section_id = ?
            `).run(now, section.id);

            return getSubmissionById(id);
        })();

        res.status(201).json(normalizeSubmissionRow(submission, true));
    } catch (err) {
        handleRouteError(res, err, "Submit section failed");
    }
});

router.get("/:sectionId/submissions", (req, res) => {
    const section = getSectionById(req.params.sectionId);
    if (!section) {
        return res.status(404).json({ error: "Section not found" });
    }

    const includeSnapshot = req.query.include_snapshot === "1" || req.query.include_snapshot === "true";
    const rows = db.prepare(`
        SELECT *
        FROM section_submissions
        WHERE section_id = ?
        ORDER BY submitted_at DESC
    `).all(section.id);

    res.json(rows.map((row) => normalizeSubmissionRow(row, includeSnapshot)));
});

router.post("/submissions/:submissionId/approve", (req, res) => {
    reviewSubmission(req, res, "approve");
});

router.post("/submissions/:submissionId/reject", (req, res) => {
    reviewSubmission(req, res, "reject");
});

function reviewSubmission(req, res, action) {
    const actor = normalizeActor(req.body?.reviewed_by || req.body?.user_id || req.get("x-user-id"));
    if (!actor) {
        return res.status(400).json({ error: "reviewed_by is required" });
    }

    try {
        const result = db.transaction(() => {
            const submission = getSubmissionById(req.params.submissionId);
            if (!submission) {
                throw createHttpError("Submission not found", 404);
            }
            if (submission.status !== "pending") {
                throw createHttpError("Submission is not pending", 409);
            }

            const now = new Date().toISOString();
            const reviewNote = normalizeOptionalString(req.body?.review_note);

            if (action === "reject") {
                updateSubmissionReview(submission.id, "rejected", actor, now, reviewNote);
                db.prepare(`
                    UPDATE section_states
                    SET status = 'rejected',
                        updated_at = ?
                    WHERE section_id = ?
                `).run(now, submission.section_id);
                return { submission: getSubmissionById(submission.id) };
            }

            const expectedHash = submission.snapshot_hash;
            if (expectedHash && expectedHash !== hashString(submission.snapshot_json)) {
                throw createHttpError("Submission snapshot hash mismatch", 409);
            }

            const snapshot = parseSnapshotJson(submission.snapshot_json);
            applySnapshotToPublished(snapshot, now);
            updateSubmissionReview(submission.id, "approved", actor, now, reviewNote);
            db.prepare(`
                UPDATE section_states
                SET status = 'approved',
                    updated_at = ?
                WHERE section_id = ?
            `).run(now, submission.section_id);

            return { submission: getSubmissionById(submission.id) };
        })();

        res.json(normalizeSubmissionRow(result.submission, true));
    } catch (err) {
        if (err && err.isConflict && req.params.submissionId) {
            const now = new Date().toISOString();
            db.prepare(`
                UPDATE section_submissions
                SET status = 'conflicted',
                    reviewed_by = ?,
                    reviewed_at = ?,
                    review_note = ?
                WHERE id = ?
                  AND status = 'pending'
            `).run(actor, now, err.message, req.params.submissionId);
        }
        handleRouteError(res, err, "Review submission failed");
    }
}

function insertCommit(options) {
    const nextNo = getNextCommitNo(options.section.id);
    const id = crypto.randomUUID();
    db.prepare(`
        INSERT INTO section_commits (
            id, section_id, parent_commit_id, commit_no, kind, restored_from_commit_id,
            created_by, created_at, title, note, snapshot_json, snapshot_hash
        )
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
        id,
        options.section.id,
        options.state.head_commit_id || null,
        nextNo,
        options.kind,
        options.restoredFromCommitId,
        options.actor,
        options.now,
        options.title,
        options.note,
        options.snapshotJson,
        options.snapshotHash
    );

    return getCommitForSection(options.section.id, id);
}

function applySnapshotToPublished(snapshot, now) {
    validateSnapshot(snapshot);
    const entities = Array.isArray(snapshot.entities) ? snapshot.entities : [];
    const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
    const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : [];

    for (const entity of entities) {
        applyEntitySnapshot(entity, now);
    }

    for (const geometry of geometries) {
        applyGeometrySnapshot(geometry, now);
    }

    for (const scope of linkScopes) {
        applyLinkScopeSnapshot(scope, now);
    }
}

function applyEntitySnapshot(entity, now) {
    const operation = normalizeOperation(entity?.operation);
    if (operation === "reference") return;

    const id = normalizeId(entity?.id);
    if (!id) throw createHttpError("Entity id is required in snapshot", 400);

    if (operation === "create") {
        db.prepare(`
            INSERT INTO entities (
                id, name, slug, description, type_id, status, is_deleted, created_at, updated_at
            )
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        `).run(
            id,
            normalizeRequiredString(entity.name),
            normalizeOptionalString(entity.slug),
            normalizeOptionalString(entity.description),
            normalizeTypeId(entity.type_id),
            normalizeInteger(entity.status, 1),
            normalizeInteger(entity.is_deleted, 0),
            now,
            now
        );
        return;
    }

    const existing = db.prepare(`SELECT * FROM entities WHERE id = ?`).get(id);
    assertBaseMatches("entity", id, existing, entity);

    if (operation === "delete") {
        db.prepare(`
            UPDATE entities
            SET is_deleted = 1,
                updated_at = ?
            WHERE id = ?
        `).run(now, id);
        db.prepare(`DELETE FROM entity_geometries WHERE entity_id = ?`).run(id);
        return;
    }

    db.prepare(`
        UPDATE entities
        SET name = ?,
            slug = ?,
            description = ?,
            type_id = ?,
            status = ?,
            is_deleted = ?,
            updated_at = ?
        WHERE id = ?
    `).run(
        normalizeRequiredString(entity.name),
        normalizeOptionalString(entity.slug),
        normalizeOptionalString(entity.description),
        normalizeTypeId(entity.type_id),
        normalizeInteger(entity.status, 1),
        normalizeInteger(entity.is_deleted, 0),
        now,
        id
    );
}

function applyGeometrySnapshot(geometry, now) {
    const operation = normalizeOperation(geometry?.operation);
    if (operation === "reference") return;

    const id = normalizeId(geometry?.id);
    if (!id) throw createHttpError("Geometry id is required in snapshot", 400);

    if (operation === "delete") {
        const existing = db.prepare(`SELECT * FROM geometries WHERE id = ?`).get(id);
        assertBaseMatches("geometry", id, existing, geometry);
        db.prepare(`
            UPDATE geometries
            SET is_deleted = 1,
                updated_at = ?
            WHERE id = ?
        `).run(now, id);
        db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(id);
        removeBindingReferenceFromAll(id, now);
        return;
    }

    const drawGeometry = geometry.draw_geometry || geometry.geometry;
    if (!drawGeometry) {
        throw createHttpError("Geometry draw_geometry is required in snapshot", 400);
    }
    const temporalRange = normalizeTemporalRange(geometry.time_start, geometry.time_end);
    const bbox = normalizeBBox(geometry.bbox) || getBBox(drawGeometry);
    const binding = normalizeIdArray(geometry.binding).filter((bindingId) => bindingId !== id);

    if (operation === "create") {
        db.prepare(`
            INSERT INTO geometries (
                id, type, is_deleted, draw_geometry, binding, time_start, time_end,
                bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat, created_at, updated_at
            )
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        `).run(
            id,
            normalizeOptionalString(geometry.type),
            normalizeInteger(geometry.is_deleted, 0),
            JSON.stringify(drawGeometry),
            serializeIdArray(binding),
            temporalRange.timeStart,
            temporalRange.timeEnd,
            bbox.minLng,
            bbox.minLat,
            bbox.maxLng,
            bbox.maxLat,
            now,
            now
        );
        return;
    }

    const existing = db.prepare(`SELECT * FROM geometries WHERE id = ?`).get(id);
    assertBaseMatches("geometry", id, existing, geometry);

    db.prepare(`
        UPDATE geometries
        SET type = ?,
            is_deleted = ?,
            draw_geometry = ?,
            binding = ?,
            time_start = ?,
            time_end = ?,
            bbox_min_lng = ?,
            bbox_min_lat = ?,
            bbox_max_lng = ?,
            bbox_max_lat = ?,
            updated_at = ?
        WHERE id = ?
    `).run(
        normalizeOptionalString(geometry.type),
        normalizeInteger(geometry.is_deleted, 0),
        JSON.stringify(drawGeometry),
        serializeIdArray(binding),
        temporalRange.timeStart,
        temporalRange.timeEnd,
        bbox.minLng,
        bbox.minLat,
        bbox.maxLng,
        bbox.maxLat,
        now,
        id
    );
}

function applyLinkScopeSnapshot(scope, now) {
    if (normalizeOperation(scope?.operation) === "reference") return;

    const geometryId = normalizeId(scope?.geometry_id);
    if (!geometryId) throw createHttpError("link_scope geometry_id is required", 400);

    const existing = db.prepare(`
        SELECT id
        FROM geometries
        WHERE id = ?
          AND is_deleted = 0
    `).get(geometryId);
    if (!existing) {
        throw createConflictError(`Geometry not found for link scope: ${geometryId}`);
    }

    const entityIds = normalizeIdArray(scope.entity_ids);
    if (!entityIds.length) {
        throw createHttpError("link_scope entity_ids must not be empty", 400);
    }

    if (scope.base_links_hash) {
        const currentHash = hashEntityLinks(geometryId);
        if (scope.base_links_hash !== currentHash) {
            throw createConflictError(`Links changed for geometry: ${geometryId}`);
        }
    }

    const placeholders = entityIds.map(() => "?").join(",");
    const rows = db.prepare(`
        SELECT id
        FROM entities
        WHERE is_deleted = 0
          AND id IN (${placeholders})
    `).all(...entityIds);
    const found = new Set(rows.map((row) => row.id));
    const missing = entityIds.filter((entityId) => !found.has(entityId));
    if (missing.length) {
        throw createHttpError(`Entity not found: ${missing.join(", ")}`, 400);
    }

    db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(geometryId);
    for (const entityId of entityIds) {
        db.prepare(`
            INSERT INTO entity_geometries (entity_id, geometry_id, created_at)
            VALUES (?, ?, ?)
        `).run(entityId, geometryId, now);
    }
}

function validateSnapshot(snapshot) {
    if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
        throw createHttpError("snapshot must be an object", 400);
    }

    const slugSet = new Set();
    for (const entity of Array.isArray(snapshot.entities) ? snapshot.entities : []) {
        const operation = normalizeOperation(entity?.operation);
        if (!operation) throw createHttpError("Invalid entity operation", 400);
        if (operation !== "delete" && operation !== "reference" && !normalizeRequiredString(entity?.name)) {
            throw createHttpError("Entity name is required", 400);
        }
        const slug = normalizeOptionalString(entity?.slug);
        if (slug) {
            const key = slug.toLowerCase();
            if (slugSet.has(key)) throw createHttpError(`Duplicate slug in snapshot: ${slug}`, 400);
            slugSet.add(key);
        }
    }

    for (const geometry of Array.isArray(snapshot.geometries) ? snapshot.geometries : []) {
        const operation = normalizeOperation(geometry?.operation);
        if (!operation) throw createHttpError("Invalid geometry operation", 400);
        if (operation === "delete" || operation === "reference") continue;

        normalizeTemporalRange(geometry?.time_start, geometry?.time_end);
        const bbox = normalizeBBox(geometry?.bbox);
        if (geometry?.bbox && !bbox) throw createHttpError("Invalid geometry bbox", 400);
        const binding = normalizeIdArray(geometry?.binding);
        if (binding.includes(normalizeId(geometry?.id))) {
            throw createHttpError("binding cannot contain self id", 400);
        }
    }

    for (const scope of Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []) {
        const entityIds = normalizeIdArray(scope?.entity_ids);
        if (!entityIds.length) {
            throw createHttpError("link_scope entity_ids must not be empty", 400);
        }
    }
}

function assertBaseMatches(kind, id, existing, snapshotItem) {
    if (!existing) {
        throw createConflictError(`${kind} not found: ${id}`);
    }
    if (snapshotItem.base_updated_at && existing.updated_at !== snapshotItem.base_updated_at) {
        throw createConflictError(`${kind} changed: ${id}`);
    }
    if (snapshotItem.base_hash) {
        const currentHash = kind === "entity" ? hashEntityRow(existing) : hashGeometryRow(existing);
        if (snapshotItem.base_hash !== currentHash) {
            throw createConflictError(`${kind} changed: ${id}`);
        }
    }
}

function getSectionById(sectionId) {
    const row = db.prepare(`
        SELECT
            s.*,
            st.status,
            st.head_commit_id,
            st.version,
            st.locked_by,
            st.locked_at,
            st.lock_expires_at
        FROM sections s
        LEFT JOIN section_states st
          ON st.section_id = s.id
        WHERE s.id = ?
    `).get(sectionId);

    return row ? normalizeSectionRow(row) : null;
}

function ensureSectionState(sectionId, now) {
    let row = db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId);
    if (row) return row;

    db.prepare(`
        INSERT INTO section_states (section_id, status, version, updated_at)
        VALUES (?, 'editing', 0, ?)
    `).run(sectionId, now);

    return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId);
}

function acquireLock(sectionId, actor, now) {
    db.prepare(`
        UPDATE section_states
        SET locked_by = ?,
            locked_at = ?,
            lock_expires_at = ?,
            updated_at = ?
        WHERE section_id = ?
    `).run(actor, now, new Date(Date.parse(now) + LOCK_TTL_MS).toISOString(), now, sectionId);

    return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId);
}

function assertCanEdit(state, actor, now) {
    if (state.status !== "editing" && state.status !== "rejected") {
        throw createHttpError("Section is not editable", 409);
    }
    assertLockAvailable(state, actor, now);
}

function assertLockAvailable(state, actor, now) {
    if (!state.locked_by) return;
    if (actor && state.locked_by === actor) return;
    if (state.lock_expires_at && Date.parse(state.lock_expires_at) <= Date.parse(now)) return;
    throw createHttpError("Section is locked by another user", 409);
}

function assertExpectedState(state, body) {
    if (body?.expected_version !== undefined && Number(body.expected_version) !== Number(state.version)) {
        throw createHttpError("Section version changed", 409);
    }
    if (
        body?.expected_head_commit_id !== undefined &&
        normalizeId(body.expected_head_commit_id) !== (state.head_commit_id || null)
    ) {
        throw createHttpError("Section head commit changed", 409);
    }
}

function setHeadCommit(sectionId, commitId, now) {
    db.prepare(`
        UPDATE section_states
        SET status = 'editing',
            head_commit_id = ?,
            version = version + 1,
            updated_at = ?
        WHERE section_id = ?
    `).run(commitId, now, sectionId);

    db.prepare(`UPDATE sections SET updated_at = ? WHERE id = ?`).run(now, sectionId);
    return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId);
}

function getNextCommitNo(sectionId) {
    const row = db.prepare(`
        SELECT COALESCE(MAX(commit_no), 0) + 1 AS next_no
        FROM section_commits
        WHERE section_id = ?
    `).get(sectionId);
    return Number(row?.next_no || 1);
}

function getCommitForSection(sectionId, commitId) {
    return db.prepare(`
        SELECT *
        FROM section_commits
        WHERE section_id = ?
          AND id = ?
    `).get(sectionId, commitId);
}

function getSubmissionById(submissionId) {
    return db.prepare(`SELECT * FROM section_submissions WHERE id = ?`).get(submissionId);
}

function updateSubmissionReview(submissionId, status, actor, now, reviewNote) {
    db.prepare(`
        UPDATE section_submissions
        SET status = ?,
            reviewed_by = ?,
            reviewed_at = ?,
            review_note = ?
        WHERE id = ?
    `).run(status, actor, now, reviewNote, submissionId);
}

function normalizeSectionRow(row) {
    return {
        id: row.id,
        title: row.title,
        description: row.description,
        user_id: row.user_id || null,
        created_by: row.created_by,
        created_at: row.created_at,
        updated_at: row.updated_at,
        state: {
            status: row.status || "editing",
            head_commit_id: row.head_commit_id || null,
            version: Number(row.version || 0),
            locked_by: row.locked_by || null,
            locked_at: row.locked_at || null,
            lock_expires_at: row.lock_expires_at || null,
        },
    };
}

function normalizeStateRow(row) {
    return {
        section_id: row.section_id,
        status: row.status,
        head_commit_id: row.head_commit_id || null,
        version: Number(row.version || 0),
        locked_by: row.locked_by || null,
        locked_at: row.locked_at || null,
        lock_expires_at: row.lock_expires_at || null,
        updated_at: row.updated_at,
    };
}

function normalizeCommitRow(row, includeSnapshot) {
    const out = {
        id: row.id,
        section_id: row.section_id,
        parent_commit_id: row.parent_commit_id || null,
        commit_no: Number(row.commit_no),
        kind: row.kind,
        restored_from_commit_id: row.restored_from_commit_id || null,
        created_by: row.created_by,
        created_at: row.created_at,
        title: row.title,
        note: row.note,
        snapshot_hash: row.snapshot_hash,
    };
    if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json);
    return out;
}

function normalizeSubmissionRow(row, includeSnapshot) {
    const out = {
        id: row.id,
        section_id: row.section_id,
        commit_id: row.commit_id,
        submitted_by: row.submitted_by,
        submitted_at: row.submitted_at,
        status: row.status,
        reviewed_by: row.reviewed_by || null,
        reviewed_at: row.reviewed_at || null,
        review_note: row.review_note || null,
        snapshot_hash: row.snapshot_hash,
    };
    if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json);
    return out;
}

function buildEmptySnapshot(section) {
    return {
        schema_version: 1,
        section: {
            id: section.id,
            title: section.title,
        },
        entities: [],
        geometries: [],
        link_scopes: [],
    };
}

function normalizeSnapshotInput(input) {
    if (typeof input === "string") {
        try {
            const snapshot = JSON.parse(input);
            return { snapshot, snapshotJson: JSON.stringify(snapshot) };
        } catch (_err) {
            return { error: "snapshot_json must be valid JSON" };
        }
    }
    if (input && typeof input === "object" && !Array.isArray(input)) {
        return { snapshot: input, snapshotJson: JSON.stringify(input) };
    }
    return { error: "snapshot is required" };
}

function parseSnapshotJson(value) {
    try {
        return JSON.parse(value);
    } catch (_err) {
        return null;
    }
}

function normalizeOperation(value) {
    const normalized = String(value || "").trim().toLowerCase();
    if (["create", "update", "delete", "reference", "replace"].includes(normalized)) {
        return normalized === "replace" ? "update" : normalized;
    }
    return null;
}

function normalizeTemporalRange(timeStartValue, timeEndValue) {
    const timeStart = normalizeOptionalInteger(timeStartValue);
    const timeEnd = normalizeOptionalInteger(timeEndValue);
    if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
        throw createHttpError("time_start must be <= time_end", 400);
    }
    return { timeStart, timeEnd };
}

function normalizeBBox(value) {
    if (!value || typeof value !== "object") return null;
    const minLng = Number(value.min_lng ?? value.minLng);
    const minLat = Number(value.min_lat ?? value.minLat);
    const maxLng = Number(value.max_lng ?? value.maxLng);
    const maxLat = Number(value.max_lat ?? value.maxLat);
    if (![minLng, minLat, maxLng, maxLat].every(Number.isFinite)) return null;
    if (minLng > maxLng || minLat > maxLat) return null;
    return { minLng, minLat, maxLng, maxLat };
}

function normalizeIdArray(value) {
    if (value === undefined || value === null || value === "") return [];
    if (!Array.isArray(value)) throw createHttpError("Expected an array of ids", 400);
    const seen = new Set();
    const ids = [];
    for (const item of value) {
        const id = normalizeId(item);
        if (!id || seen.has(id)) continue;
        seen.add(id);
        ids.push(id);
    }
    return ids;
}

function serializeIdArray(value) {
    return value.length ? JSON.stringify(value) : null;
}

function removeBindingReferenceFromAll(removedGeometryId, now) {
    const removedId = String(removedGeometryId);
    const rows = db.prepare(`
        SELECT id, binding
        FROM geometries
        WHERE is_deleted = 0
          AND binding IS NOT NULL
          AND binding != ''
    `).all();

    for (const row of rows) {
        let binding;
        try {
            binding = JSON.parse(row.binding);
        } catch (_err) {
            binding = [];
        }
        const next = normalizeIdArray(binding).filter((id) => id !== removedId);
        if (next.length === binding.length) continue;
        db.prepare(`
            UPDATE geometries
            SET binding = ?,
                updated_at = ?
            WHERE id = ?
        `).run(serializeIdArray(next), now, row.id);
    }
}

function hashEntityLinks(geometryId) {
    const rows = db.prepare(`
        SELECT entity_id
        FROM entity_geometries
        WHERE geometry_id = ?
        ORDER BY entity_id ASC
    `).all(geometryId);
    return hashObject(rows.map((row) => row.entity_id));
}

function hashEntityRow(row) {
    return hashObject({
        id: row.id,
        name: row.name,
        slug: row.slug,
        description: row.description,
        type_id: row.type_id,
        status: row.status,
        is_deleted: row.is_deleted,
    });
}

function hashGeometryRow(row) {
    return hashObject({
        id: row.id,
        type: row.type,
        is_deleted: row.is_deleted,
        draw_geometry: row.draw_geometry,
        binding: row.binding,
        time_start: row.time_start,
        time_end: row.time_end,
        bbox_min_lng: row.bbox_min_lng,
        bbox_min_lat: row.bbox_min_lat,
        bbox_max_lng: row.bbox_max_lng,
        bbox_max_lat: row.bbox_max_lat,
    });
}

function hashObject(value) {
    return hashString(JSON.stringify(value));
}

function hashString(value) {
    return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
}

function normalizeActor(value) {
    return normalizeId(value) || "anonymous";
}

function normalizeId(value) {
    if (value === undefined || value === null || value === "") return null;
    const normalized = String(value).trim();
    return normalized || null;
}

function normalizeRequiredString(value) {
    if (typeof value !== "string") return null;
    const trimmed = value.trim();
    return trimmed.length ? trimmed : null;
}

function normalizeOptionalString(value) {
    if (value === undefined || value === null) return null;
    if (typeof value !== "string") return null;
    const trimmed = value.trim();
    return trimmed.length ? trimmed : null;
}

function normalizeOptionalInteger(value) {
    if (value === undefined || value === null || value === "") return null;
    const num = Number(value);
    if (!Number.isFinite(num)) throw createHttpError("Expected a number", 400);
    return Math.trunc(num);
}

function normalizeInteger(value, fallback) {
    if (value === undefined || value === null || value === "") return fallback;
    const num = Number(value);
    return Number.isFinite(num) ? Math.trunc(num) : fallback;
}

function normalizeTypeId(value) {
    const normalized = normalizeOptionalString(value);
    return normalized ? normalized.toLowerCase() : "country";
}

function isSqliteConstraint(err) {
    const code = err?.code || "";
    return code === "SQLITE_CONSTRAINT" || String(code).startsWith("SQLITE_CONSTRAINT_");
}

function createHttpError(message, status = 400) {
    const err = new Error(message);
    err.status = status;
    return err;
}

function createConflictError(message) {
    const err = createHttpError(message, 409);
    err.isConflict = true;
    return err;
}

function handleRouteError(res, err, fallbackMessage) {
    if (err.status) {
        return res.status(err.status).json({ error: err.message });
    }
    if (isSqliteConstraint(err)) {
        return res.status(409).json({ error: "Database constraint failed" });
    }
    console.error(fallbackMessage, err);
    return res.status(500).json({ error: fallbackMessage });
}

module.exports = router;
