Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.spice.profitmandi.web.controller;

import com.spice.profitmandi.common.web.util.ResponseSender;
import com.spice.profitmandi.dao.entity.auth.AuthUser;
import com.spice.profitmandi.dao.entity.user.Lead;
import com.spice.profitmandi.dao.entity.user.LeadActivity;
import com.spice.profitmandi.dao.entity.user.LeadCall;
import com.spice.profitmandi.dao.entity.user.LeadDnd;
import com.spice.profitmandi.dao.entity.user.LeadLiveLocation;
import com.spice.profitmandi.dao.enumuration.dtr.CommunicationType;
import com.spice.profitmandi.dao.enumuration.dtr.LeadDisposition;
import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
import com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository;
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
import com.spice.profitmandi.service.LmsAssignmentService;
import com.spice.profitmandi.web.model.LoginDetails;
import com.spice.profitmandi.web.util.CookiesProcessor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import com.spice.profitmandi.dao.model.LeadBrandModel;
import com.spice.profitmandi.dao.model.LeadDetailModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

/**
 * LMS operating core — the per-lead screen and the two mutations it drives (SOP §8/§10/§12).
 *
 * <ul>
 *   <li><b>{@code GET /leadRecord}</b> — full-page lead record fragment ({@code lead-record.vm}),
 *       loaded into {@code #main-content} and also opened from the standalone dashboard.</li>
 *   <li><b>{@code POST /lms/advanceStage}</b> — forward-only stage move with a mandatory comment.</li>
 *   <li><b>{@code POST /lms/disposition}</b> — call outcome; drives the stage transition, stamps
 *       first contact (closing the SLA) and appends to the trail.</li>
 *   <li><b>{@code GET /lms/resolve-region}</b> — region &rarr; owning BM/RSM preview, used by the
 *       Create-Lead form and the dashboard's Auto-Assignment tab.</li>
 * </ul>
 *
 * Every mutation appends an immutable {@link LeadActivity} row and keeps the legacy
 * {@code Lead.status} in sync via {@link LeadStage#toLegacyStatus()}, so the existing lead lists,
 * exports and beat flows keep working untouched. Read-side aggregation lives in
 * {@code LmsDashboardService}; assignment/SLA rules live in {@link LmsAssignmentService}.
 */
@Controller
@Transactional(rollbackFor = Throwable.class)
public class LmsLeadController {

    private static final Logger LOGGER = LogManager.getLogger(LmsLeadController.class);

    /**
     * How far back to look when binding a disposition to a call the browser did not name. Long
     * enough to cover a call plus the agent typing up the outcome; short enough that this morning's
     * call never gets attached to this afternoon's disposition.
     */
    private static final int CALL_BIND_WINDOW_MINUTES = 30;

    /** Trail/record timestamp format, shared by every date the record page prints. */
    private static final DateTimeFormatter RECORD_FORMAT = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm");

    @Autowired
    private LeadRepository leadRepository;

    @Autowired
    private LeadActivityRepository leadActivityRepository;

    @Autowired
    private LeadLiveLocationRepository leadLiveLocationRepository;

    @Autowired
    private LeadCallRepository leadCallRepository;

    @Autowired
    private LeadDndRepository leadDndRepository;

    @Autowired
    private AuthRepository authRepository;

    @Autowired
    private RegionRepository regionRepository;

    @Autowired
    private LmsAssignmentService lmsAssignmentService;

    @Autowired
    private CookiesProcessor cookiesProcessor;

    @Autowired
    private ResponseSender<?> responseSender;

    // ---- Record page ------------------------------------------------------------------------

    /**
     * Full lead record: details, stepper, append-only trail, SLA card and the act-on-it controls.
     * Returned as a fragment — the caller drops it into {@code #main-content}.
     */
    // Same property the Leads screen's Generate Link button uses, so both produce an identical URL.
    @Value("${lead.geo.public.base-url:}")
    private String leadGeoPublicBaseUrl;

    @RequestMapping(value = "/leadRecord", method = RequestMethod.GET)
    public String leadRecord(@RequestParam(name = "leadId") int leadId, Model model) {
        Lead lead = leadRepository.selectById(leadId);
        if (lead == null) {
            // Fragment contract: whatever comes back is dropped straight into #main-content.
            model.addAttribute("response1",
                    "<div class=\"alert alert-warning\">Lead #" + leadId + " not found.</div>");
            return "response";
        }

        LeadStage effectiveStage = lead.getEffectiveStage();
        model.addAttribute("lead", lead);
        model.addAttribute("effectiveStage", effectiveStage);
        model.addAttribute("stageIndex", happyPathIndex(effectiveStage));
        model.addAttribute("terminal", isTerminal(effectiveStage));
        model.addAttribute("stages", LeadStage.HAPPY_PATH);
        model.addAttribute("dispositions", LeadDisposition.values());
        model.addAttribute("slaState", lmsAssignmentService.slaState(lead));
        model.addAttribute("dateTimeFormatter", RECORD_FORMAT);

        // Region picker in the edit modal — the same list the Create-Lead form offers.
        model.addAttribute("regions", regionRepository.selectAll());

        model.addAttribute("owner", lead.getAssignTo() > 0 ? authRepository.selectById(lead.getAssignTo()) : null);
        model.addAttribute("bm", (lead.getOwnerBmId() != null && lead.getOwnerBmId() > 0)
                ? authRepository.selectById(lead.getOwnerBmId()) : null);

        LeadLiveLocation geo = leadLiveLocationRepository.selectByLeadId(leadId);
        model.addAttribute("geo", geo);

        // A blocked number must be visible before the agent reaches for the call button, not only
        // discovered when the dial is refused.
        model.addAttribute("dnd", leadDndRepository.selectByMobile(lead.getLeadMobile()));
        List<LeadCall> calls = leadCallRepository.selectByLeadId(leadId);
        model.addAttribute("calls", calls);
        // Keyed by id so a disposition in the trail can show the recording of the call it came from.
        // A disposition is the agent's account of the conversation; having to hunt for the audio in a
        // separate list to check it against the recording is the friction worth removing here.
        Map<Integer, LeadCall> callById = new HashMap<>();
        if (calls != null) {
            for (LeadCall c : calls) {
                callById.put(c.getId(), c);
            }
        }
        model.addAttribute("callById", callById);

        // Append-only trail, newest first, with actor names resolved in one batch.
        List<LeadActivity> trail = leadActivityRepository.selectBYLeadId(leadId);
        if (trail == null) {
            trail = new ArrayList<>();
        }
        trail.sort((a, b) -> {
            LocalDateTime ta = a.getCreatedTimestamp();
            LocalDateTime tb = b.getCreatedTimestamp();
            if (ta == null && tb == null) {
                return 0;
            }
            if (ta == null) {
                return 1;
            }
            if (tb == null) {
                return -1;
            }
            return tb.compareTo(ta);
        });
        model.addAttribute("trail", trail);
        model.addAttribute("authUserMap", actorsOf(trail));

        return "lead-record";
    }

    // ---- Mutations --------------------------------------------------------------------------

    /**
     * Move a lead forward along the happy path (or to a terminal state). Forward-only: the enum
     * decides what is legal, so a stale page cannot walk a lead backwards. Comment is mandatory —
     * it becomes the trail entry.
     */
    @RequestMapping(value = "/lms/advanceStage", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<?> advanceStage(HttpServletRequest request,
                                          @RequestParam(name = "leadId") int leadId,
                                          @RequestParam(name = "toStage") String toStage,
                                          @RequestParam(name = "comment") String comment) {
        if (comment == null || comment.trim().isEmpty()) {
            return responseSender.badRequest("A comment is required to change the stage");
        }
        Lead lead = leadRepository.selectById(leadId);
        if (lead == null) {
            return responseSender.notFound("Lead not found");
        }

        LeadStage target;
        try {
            target = LeadStage.valueOf(toStage);
        } catch (IllegalArgumentException e) {
            return responseSender.badRequest("Unknown stage: " + toStage);
        }

        LeadStage current = lead.getEffectiveStage();
        if (isTerminal(current)) {
            return responseSender.badRequest(pretty(current) + " is a terminal state — the lead cannot be moved on");
        }
        if (!current.canAdvanceTo(target)) {
            return responseSender.badRequest("Cannot move from " + pretty(current) + " to " + pretty(target));
        }

        AuthUser actor = currentUser(request);
        applyStage(lead, target);
        leadRepository.persist(lead);
        appendTrail(leadId, actor, "Stage " + pretty(current) + " → " + pretty(target) + ": " + comment.trim(), null, null);

        LOGGER.info("LMS stage advanced: lead {} {} -> {} by {}", leadId, current, target,
                actor != null ? actor.getId() : 0);
        return responseSender.ok(stateOf(lead));
    }

    /**
     * Record a call outcome (SOP §12.2). The disposition drives the stage transition; the first
     * dispositioned contact stamps {@code firstContactedAt}, which is what closes the 5-hr SLA.
     */
    @RequestMapping(value = "/lms/disposition", method = RequestMethod.POST,
            consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public ResponseEntity<?> disposition(HttpServletRequest request,
                                         @RequestBody DispositionRequest body) {
        if (body == null || body.disposition == null || body.disposition.trim().isEmpty()) {
            return responseSender.badRequest("A disposition is required");
        }
        Lead lead = leadRepository.selectById(body.leadId);
        if (lead == null) {
            return responseSender.notFound("Lead not found");
        }

        LeadDisposition disposition;
        try {
            disposition = LeadDisposition.valueOf(body.disposition);
        } catch (IllegalArgumentException e) {
            return responseSender.badRequest("Unknown disposition: " + body.disposition);
        }
        if (disposition == LeadDisposition.INTERESTED && (body.value == null || body.value <= 0)) {
            return responseSender.badRequest("Business value is required on an INTERESTED disposition");
        }

        AuthUser actor = currentUser(request);
        LeadStage before = lead.getEffectiveStage();
        LeadCall call = resolveCall(body, lead, actor);

        lead.setDisposition(disposition);
        lead.setDispositionSubReason(trimToNull(body.subReason));
        // Interim manual path — drops out when the dialer starts filling recordings from the webhook.
        if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {
            lead.setRecordingUrl(body.recordingUrl.trim());
        }
        // Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody
        // answered) and WRONG_NUMBER (not the retailer) are not contact: they must not satisfy the SLA,
        // and they must not disturb a stamp an earlier real contact already set.
        if (lead.getFirstContactedAt() == null && countsAsContact(disposition)) {
            // Prefer the moment the retailer actually picked up over "now" — the agent may sit on the
            // disposition modal for minutes, and that gap would silently eat into the 5-hr SLA.
            LocalDateTime contactedAt = (call != null && call.getAnsweredAt() != null)
                    ? call.getAnsweredAt() : LocalDateTime.now();
            lead.setFirstContactedAt(contactedAt);
        }

        LocalDateTime scheduled = parseLocal(body.callbackAt != null ? body.callbackAt : body.followUpAt);
        applyDispositionStage(lead, disposition, body);
        leadRepository.persist(lead);

        CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)
                ? CommunicationType.VISIT : CommunicationType.TELEPHONIC;
        LeadActivity activity = appendTrail(body.leadId, actor,
                dispositionRemark(disposition, before, lead, body), type, scheduled,
                call != null ? call.getId() : null);

        // Bind both ways: the trail entry knows its call (for duration + a recording link), and the
        // call knows the disposition it produced (so an unactioned call is findable).
        if (call != null) {
            call.setLeadActivityId(activity.getId());
            leadCallRepository.persist(call);
        }

        if (disposition == LeadDisposition.DO_NOT_CALL) {
            blockFurtherCalls(lead, actor, body);
        }

        LOGGER.info("LMS disposition {} on lead {} ({} -> {}) by {}, call {}", disposition, body.leadId, before,
                lead.getEffectiveStage(), actor != null ? actor.getId() : 0, call != null ? call.getId() : null);
        return responseSender.ok(stateOf(lead));
    }

    /**
     * Which call this disposition is about. The browser normally passes {@code leadCallId} straight
     * from the dialer; when it cannot (agent dialled from their own handset, or the page reloaded
     * mid-call) we fall back to this agent's most recent call on this lead that no disposition has
     * claimed yet. Deliberately narrow — binding the wrong call is worse than binding none, because
     * it would attach someone else's recording to this lead's trail.
     */
    private LeadCall resolveCall(DispositionRequest body, Lead lead, AuthUser actor) {
        if (body.leadCallId != null && body.leadCallId > 0) {
            LeadCall call = leadCallRepository.selectById(body.leadCallId);
            if (call != null && call.getLeadId() == lead.getId()) {
                return call;
            }
            LOGGER.warn("Ignoring leadCallId {} on lead {} — missing or belongs to another lead",
                    body.leadCallId, lead.getId());
            return null;
        }
        if (actor == null) {
            return null;
        }
        LeadCall latest = leadCallRepository.selectLatestByLeadIdAndAuthId(lead.getId(), actor.getId());
        if (latest == null || latest.getLeadActivityId() != null) {
            return null;
        }
        LocalDateTime startedAt = latest.getStartedAt() != null ? latest.getStartedAt() : latest.getCreatedTimestamp();
        if (startedAt == null || startedAt.isBefore(LocalDateTime.now().minusMinutes(CALL_BIND_WINDOW_MINUTES))) {
            return null;
        }
        return latest;
    }

    /**
     * DO_NOT_CALL means never ring this retailer again (SOP §17). Blocks the number, not the lead —
     * the same person recurs as several leads and a per-lead block would not hold. Before this the
     * disposition only moved the stage, and nothing stopped a re-dial.
     */
    private void blockFurtherCalls(Lead lead, AuthUser actor, DispositionRequest body) {
        LeadDnd dnd = new LeadDnd();
        dnd.setMobile(lead.getLeadMobile());
        dnd.setLeadId(lead.getId());
        dnd.setAuthId(actor != null ? actor.getId() : null);
        dnd.setSource("DISPOSITION");
        dnd.setReason(trimToNull(body.note) != null
                ? trimToNull(body.note) : "Retailer asked not to be contacted");
        leadDndRepository.block(dnd);
        LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);
    }

    /**
     * Correct the record's own fields (SOP §6).
     *
     * <p>Everything else on this screen either moves the lead forward or logs a conversation;
     * nothing could fix a misheard shop name or a location that was never captured. Between the
     * create form (which writes {@code address}/{@code city}/{@code state} empty and leaves the
     * geo link to supply the real location) and the legacy Leads edit (which only touches status,
     * assignee, city and state, and is gated behind an approved geo-pin), a lead's business name,
     * address and region were unreachable once created.
     *
     * <p>What stays immutable is what the SOP makes immutable (§7.1): the LMS id, the created
     * stamp, the creation path, the stage and the SLA clock. Region is editable but is not a plain
     * field edit — it re-resolves the owning BM/RSM, so it is handled as a re-assignment below.
     *
     * <p>Every accepted change writes its own trail entry. One entry per field rather than one per
     * save, so "who changed this number, and when" is answerable without diffing two saves.
     */
    @RequestMapping(value = "/lms/updateLead", method = RequestMethod.POST,
            consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public ResponseEntity<?> updateLead(HttpServletRequest request, @RequestBody UpdateLeadRequest body) {
        if (body == null) {
            return responseSender.badRequest("Nothing to update");
        }
        Lead lead = leadRepository.selectById(body.leadId);
        if (lead == null) {
            return responseSender.notFound("Lead not found");
        }
        AuthUser me = currentUser(request);
        if (me == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        String name = trimToNull(body.retailerName);
        if (name == null) {
            return responseSender.badRequest("Retailer name is required");
        }
        String mobile = digitsOnly(body.mobile);
        if (mobile == null || mobile.length() < 10) {
            return responseSender.badRequest("A valid 10-digit contact number is required");
        }
        mobile = mobile.substring(mobile.length() - 10);

        // A number change is the one edit that can quietly undo a compliance decision, so it gets
        // the same two checks the create form runs rather than being trusted as a typo fix.
        boolean mobileChanged = !mobile.equals(lead.getLeadMobile());
        if (mobileChanged) {
            if (leadDndRepository.selectByMobile(mobile) != null) {
                return responseSender.badRequest("That number is on the do-not-call register.");
            }
            Lead other = leadRepository.selectByMobileNumber(mobile);
            if (other != null && other.getId() != lead.getId()) {
                return responseSender.badRequest("Lead #" + other.getId() + " already exists for " + mobile);
            }
        }

        List<String> entries = new ArrayList<>();

        String beforeName = joinName(lead.getFirstName(), lead.getLastName());
        if (!name.equals(beforeName)) {
            int space = name.lastIndexOf(' ');
            lead.setFirstName(space > 0 ? name.substring(0, space) : name);
            // user.lead.last_name is NOT NULL — a single-word name keeps an empty surname, not null.
            lead.setLastName(space > 0 ? name.substring(space + 1) : "");
            entries.add(fieldChange("Retailer", beforeName, name));
        }
        if (mobileChanged) {
            entries.add(fieldChange("Contact", lead.getLeadMobile(), mobile));
            lead.setLeadMobile(mobile);
        }

        String outlet = trimToNull(body.businessName);
        if (!same(lead.getOutLetName(), outlet)) {
            entries.add(fieldChange("Business", lead.getOutLetName(), outlet));
            lead.setOutLetName(outlet == null ? "" : outlet);
        }

        // address / city / state are NOT NULL — blanked fields are written empty, never null.
        String address = trimToEmpty(body.address);
        String city = trimToEmpty(body.city);
        String state = trimToEmpty(body.state);
        String beforeLocation = locationOf(lead.getAddress(), lead.getCity(), lead.getState());
        String afterLocation = locationOf(address, city, state);
        if (!beforeLocation.equals(afterLocation)) {
            lead.setAddress(address);
            lead.setCity(city);
            lead.setState(state);
            entries.add(fieldChange("Location", beforeLocation, afterLocation));
        }

        if (body.potential != null && body.potential >= 0 && body.potential != lead.getPotential()) {
            entries.add(fieldChange("Business value",
                    lead.getPotential() > 0 ? money(lead.getPotential()) : null, money(body.potential)));
            lead.setPotential(body.potential);
        }

        // Region last: it is a re-assignment, and it reads better in the trail after the field edits.
        if (body.regionId != null && body.regionId > 0 && !body.regionId.equals(lead.getRegionId())) {
            LmsAssignmentService.Assignment a = lmsAssignmentService.resolve(body.regionId);
            if (a.regionId == null) {
                return responseSender.badRequest("Unknown region");
            }
            Integer previousBm = lead.getOwnerBmId();
            String beforeRegion = lead.getRegionCode();
            lead.setRegionId(a.regionId);
            lead.setRegionCode(a.regionCode);
            lead.setAssignmentStatus(a.assignmentStatus);
            lead.setOwnerBmId(a.bm != null ? a.bm.getId() : null);

            // Follow the region only while the lead is still sitting with whoever the engine picked.
            // Once a BM has handed it to a named ASM, that mapping is a decision — moving the region
            // must not silently undo it.
            boolean stillAutoAssigned = lead.getAssignTo() <= 0
                    || (previousBm != null && lead.getAssignTo() == previousBm.intValue());
            if (stillAutoAssigned) {
                lead.setAssignTo(a.bm != null ? a.bm.getId() : 0);
            }

            StringBuilder move = new StringBuilder(fieldChange("Region", beforeRegion, a.regionCode));
            if (a.bm != null) {
                move.append(" · owner re-resolved to ").append(a.bm.getFullName());
                if (!stillAutoAssigned) {
                    move.append(" (working owner left unchanged — the lead is mapped to a named user)");
                }
            } else {
                move.append(" · no active BM/RSM for that region, moved to the HOLD queue");
            }
            entries.add(move.toString());
        }

        if (entries.isEmpty()) {
            return responseSender.badRequest("Nothing changed");
        }

        lead.setUpdatedTimestamp(LocalDateTime.now());
        leadRepository.persist(lead);
        for (String entry : entries) {
            trail(lead.getId(), me.getId(), entry);
        }

        LOGGER.info("LMS lead {} edited by auth {} — {}", lead.getId(), me.getId(), entries);
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("ok", true);
        out.put("leadId", lead.getId());
        out.put("changes", entries.size());
        return responseSender.ok(out);
    }

    /** Payload for {@link #updateLead}. Only the fields this screen is allowed to correct. */
    public static class UpdateLeadRequest {
        public int leadId;
        public String retailerName;
        public String businessName;
        public String mobile;
        public String address;
        public String city;
        public String state;
        public Integer regionId;
        public Double potential;
    }

    /** Trail line for one edited field. Blank before/after read as an em dash, not as nothing. */
    private String fieldChange(String label, String before, String after) {
        return label + " " + orDash(before) + " → " + orDash(after);
    }

    private String orDash(String s) {
        return (s == null || s.trim().isEmpty()) ? "—" : s.trim();
    }

    private boolean same(String a, String b) {
        return orDash(a).equals(orDash(b));
    }

    private String trimToEmpty(String s) {
        return s == null ? "" : s.trim();
    }

    private String joinName(String first, String last) {
        String joined = ((first == null ? "" : first) + " " + (last == null ? "" : last)).trim();
        return joined.replaceAll("\\s+", " ");
    }

    /** "address, city, state" with the empty parts dropped — what the record row prints. */
    private String locationOf(String address, String city, String state) {
        StringBuilder sb = new StringBuilder();
        for (String part : new String[]{address, city, state}) {
            if (part == null || part.trim().isEmpty()) {
                continue;
            }
            if (sb.length() > 0) {
                sb.append(", ");
            }
            sb.append(part.trim());
        }
        return sb.toString();
    }

    private String money(double value) {
        return "₹" + String.format("%.0f", value) + "/mo";
    }

    /**
     * Region &rarr; owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead
     * assignment preview and the dashboard's routing table; {@code HOLD} means the region has no
     * active owner and a lead created against it would wait in the HOLD queue.
     */
    @RequestMapping(value = "/lms/resolve-region", method = RequestMethod.GET)
    @ResponseBody
    public ResponseEntity<?> resolveRegion(@RequestParam(name = "regionId", required = false) Integer regionId) {
        LmsAssignmentService.Assignment assignment = lmsAssignmentService.resolve(regionId);
        Map<String, Object> out = new HashMap<>();
        out.put("regionId", assignment.regionId);
        out.put("regionCode", assignment.regionCode);
        out.put("assignmentStatus", assignment.assignmentStatus);
        out.put("bmId", assignment.bm != null ? assignment.bm.getId() : null);
        out.put("bmName", assignment.bm != null ? assignment.bm.getFullName() : null);
        return responseSender.ok(out);
    }

    // ---- Stage rules ------------------------------------------------------------------------

    /**
     * Stage effect of each disposition (SOP §12.2). Only INTERESTED moves the lead forward; the
     * negative outcomes are terminal, and NOT_REACHABLE drops the lead once the retry budget is
     * spent. Anything else just records that contact happened.
     */
    private void applyDispositionStage(Lead lead, LeadDisposition disposition, DispositionRequest body) {
        switch (disposition) {
            case INTERESTED:
                if (body.value != null && body.value > 0) {
                    lead.setPotential(body.value);
                }
                advanceIfForward(lead, LeadStage.QUALIFIED);
                break;
            case NOT_INTERESTED:
            case DO_NOT_CALL:
                applyStage(lead, LeadStage.NOT_INTERESTED);
                break;
            case WRONG_NUMBER:
                // A corrected number keeps the lead alive at its current stage — the retailer still
                // has not been spoken to, so nothing advances. A blank one closes the lead.
                String corrected = digitsOnly(body.correctedNumber);
                if (corrected != null && corrected.length() == 10) {
                    lead.setLeadMobile(corrected);
                    lead.setUpdatedTimestamp(LocalDateTime.now());
                } else {
                    applyStage(lead, LeadStage.DROPPED);
                }
                break;
            case NOT_REACHABLE:
                int tries = (lead.getUnreachableCount() == null ? 0 : lead.getUnreachableCount()) + 1;
                lead.setUnreachableCount(tries);
                if (tries >= LmsAssignmentService.MAX_UNREACHABLE) {
                    applyStage(lead, LeadStage.DROPPED);
                }
                break;
            case CALLBACK:
            case FOLLOW_UP:
            default:
                advanceIfForward(lead, LeadStage.CONTACTED);
                break;
        }
    }

    /** Did the retailer actually get spoken to? Only those outcomes may close the first-contact SLA. */
    private boolean countsAsContact(LeadDisposition disposition) {
        return disposition != LeadDisposition.NOT_REACHABLE && disposition != LeadDisposition.WRONG_NUMBER;
    }

    /** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */
    private void applyStage(Lead lead, LeadStage stage) {
        lead.setStage(stage);
        lead.setStatus(stage.toLegacyStatus());
        lead.setUpdatedTimestamp(LocalDateTime.now());
    }

    /** Move to {@code stage} only if that is a forward move — a later call never rewinds the lead. */
    private void advanceIfForward(Lead lead, LeadStage stage) {
        LeadStage current = lead.getEffectiveStage();
        if (current == stage || current.canAdvanceTo(stage)) {
            applyStage(lead, stage);
        } else {
            lead.setUpdatedTimestamp(LocalDateTime.now());
        }
    }

    // ---- Helpers ----------------------------------------------------------------------------

    private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
                                     LocalDateTime scheduled) {
        return appendTrail(leadId, actor, remark, type, scheduled, null);
    }

    /** As above, additionally linking the trail entry to the call that produced it. */
    private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
                                     LocalDateTime scheduled, Integer leadCallId) {
        LeadActivity activity = new LeadActivity();
        activity.setLeadId(leadId);
        activity.setRemark(remark);
        activity.setAuthId(actor != null ? actor.getId() : 0);
        activity.setCommunicationType(type);
        activity.setSchelduleTimestamp(scheduled);
        activity.setLeadCallId(leadCallId);
        activity.setCreatedTimestamp(LocalDateTime.now());
        leadActivityRepository.persist(activity);
        return activity;
    }

    /** Human-readable trail line for a disposition, including the stage move it caused. */
    private String dispositionRemark(LeadDisposition disposition, LeadStage before, Lead lead,
                                     DispositionRequest body) {
        StringBuilder sb = new StringBuilder(pretty(disposition.name()));
        if (body.subReason != null && !body.subReason.trim().isEmpty()) {
            sb.append(" · ").append(body.subReason.trim());
        }
        if (disposition == LeadDisposition.NOT_REACHABLE) {
            sb.append(" · attempt ").append(lead.getUnreachableCount());
            if (body.retrySchedule != null && !body.retrySchedule.trim().isEmpty()) {
                sb.append(", retry ").append(body.retrySchedule.trim());
            }
        }
        LeadStage after = lead.getEffectiveStage();
        if (after != before) {
            sb.append(" · stage ").append(pretty(before)).append(" → ").append(pretty(after));
        }
        if (body.note != null && !body.note.trim().isEmpty()) {
            sb.append(" — ").append(body.note.trim());
        }
        return sb.toString();
    }

    /** What the caller needs to refresh its row without re-reading the whole record. */
    private Map<String, Object> stateOf(Lead lead) {
        LeadStage stage = lead.getEffectiveStage();
        Map<String, Object> out = new HashMap<>();
        out.put("leadId", lead.getId());
        out.put("stage", stage.name());
        out.put("stageLabel", pretty(stage));
        out.put("terminal", isTerminal(stage));
        out.put("slaState", lmsAssignmentService.slaState(lead));
        return out;
    }

    private Map<Integer, AuthUser> actorsOf(List<LeadActivity> trail) {
        Set<Integer> ids = new HashSet<>();
        for (LeadActivity a : trail) {
            if (a.getAuthId() > 0) {
                ids.add(a.getAuthId());
            }
        }
        Map<Integer, AuthUser> actors = new HashMap<>();
        if (!ids.isEmpty()) {
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
                actors.put(u.getId(), u);
            }
        }
        return actors;
    }

    private AuthUser currentUser(HttpServletRequest request) {
        try {
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
            if (loginDetails != null && loginDetails.getEmailId() != null) {
                return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
            }
        } catch (Exception e) {
            LOGGER.warn("Could not resolve the acting user for an LMS mutation", e);
        }
        return null;
    }

    private int happyPathIndex(LeadStage stage) {
        for (int i = 0; i < LeadStage.HAPPY_PATH.length; i++) {
            if (LeadStage.HAPPY_PATH[i] == stage) {
                return i;
            }
        }
        return -1;
    }

    private boolean isTerminal(LeadStage stage) {
        return stage == LeadStage.NOT_INTERESTED || stage == LeadStage.DROPPED;
    }

    private String pretty(LeadStage stage) {
        return stage == null ? "—" : pretty(stage.name());
    }

    private String pretty(String enumName) {
        return enumName.replace('_', ' ');
    }

    private String trimToNull(String s) {
        return (s == null || s.trim().isEmpty()) ? null : s.trim();
    }

    private String digitsOnly(String s) {
        return s == null ? null : s.replaceAll("\\D", "");
    }

    /** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */
    private LocalDateTime parseLocal(String s) {
        if (s == null || s.trim().isEmpty()) {
            return null;
        }
        try {
            return LocalDateTime.parse(s.trim());
        } catch (Exception e) {
            LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);
            return null;
        }
    }

    /** JSON body of the disposition modal — one flat shape, sub-fields set per disposition. */
    public static class DispositionRequest {
        public int leadId;
        public String disposition;
        public String note;
        /** The {@code user.lead_call} row this outcome belongs to, supplied by the dialer. */
        public Integer leadCallId;
        /**
         * Interim: the free-text recording URL agents paste today. Superseded by {@code leadCallId}
         * once the dialer captures recordings automatically — kept until then so removing the box
         * does not leave leads with no recording path at all.
         */
        public String recordingUrl;
        public Double value;          // INTERESTED
        public String callbackAt;     // CALLBACK
        public String retrySchedule;  // NOT_REACHABLE
        public String correctedNumber;// WRONG_NUMBER
        public String followUpType;   // FOLLOW_UP — CALL | MEETING
        public String followUpAt;     // FOLLOW_UP
        public String subReason;      // NOT_INTERESTED
    }

    /**
     * Create a lead from the LMS dashboard.
     *
     * <p><b>Deliberately not {@code /createLead}.</b> That endpoint is shared with the legacy Leads
     * screen and must keep behaving exactly as it does. It also silently ignores the {@code regionId}
     * and {@code creationPath} this dashboard has always sent — which is why no lead has ever had a
     * stage, an LMS code or an SLA clock, while the UI cheerfully toasted "auto-assigned · SLA
     * started". This endpoint is what makes that message true.
     *
     * <p>Asks for far less than the legacy form: no store photos, no counter size, no brand-wise
     * values, no free-text address. The store-board photo and lat/lng arrive through the geo capture
     * link (auto-generated below), and location is confirmed by that link or by manual verification —
     * so an agent on a first call is no longer made to invent shop-audit data.
     */
    @RequestMapping(value = "/lms/createLead", method = RequestMethod.POST,
            consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public ResponseEntity<?> createLead(HttpServletRequest request, @RequestBody CreateLeadRequest body) {
        if (body == null) {
            return responseSender.badRequest("Nothing to create");
        }
        String name = body.firstName == null ? "" : body.firstName.trim();
        if (name.isEmpty()) {
            return responseSender.badRequest("Retailer name is required");
        }
        String mobile = body.mobile == null ? "" : body.mobile.replaceAll("\\D", "");
        if (mobile.length() < 10) {
            return responseSender.badRequest("A valid 10-digit contact number is required");
        }
        mobile = mobile.substring(mobile.length() - 10);
        if (body.regionId == null || body.regionId <= 0) {
            return responseSender.badRequest("Pick a region — it drives auto-assignment");
        }

        // Same rule the dialer uses: a number on the do-not-call register never becomes a lead.
        if (leadDndRepository.selectByMobile(mobile) != null) {
            return responseSender.badRequest("This number is on the do-not-call register.");
        }

        Lead existing = leadRepository.selectByMobileNumber(mobile);
        if (existing != null) {
            return responseSender.badRequest("Lead #" + existing.getId() + " already exists for "
                    + mobile + ", created by " + existing.getCreatedBy());
        }

        AuthUser me = currentUser(request);
        if (me == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        Lead lead = new Lead();
        // Split on the last space so "Jitu Katara" keeps a surname; user.lead.last_name is NOT NULL.
        int space = name.lastIndexOf(' ');
        lead.setFirstName(space > 0 ? name.substring(0, space) : name);
        lead.setLastName(space > 0 ? name.substring(space + 1) : "");
        lead.setLeadMobile(mobile);
        // address / city / state are NOT NULL but are no longer asked for: the geo link supplies the
        // real location. Coalesced rather than left to fail the insert.
        lead.setAddress("");
        lead.setCity("");
        lead.setState("");
        lead.setSource(body.source == null || body.source.trim().isEmpty() ? "LMS Dashboard" : body.source.trim());
        lead.setCreatedTimestamp(LocalDateTime.now());
        lead.setUpdatedTimestamp(LocalDateTime.now());
        lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
        lead.setAuthId(me.getId());
        // The record screen reads the business name off user.lead.outlet_name. Until now it was only
        // written to lead_detail below — a call that throws for every lead created here, because the
        // legacy detail path demands store photos this form deliberately does not collect — so the
        // name the agent typed was silently dropped and the Business row rendered blank.
        lead.setOutLetName(body.outletName == null ? "" : body.outletName.trim());
        lead.setRegionId(body.regionId);
        if (body.potential != null && body.potential > 0) {
            lead.setPotential(body.potential);
        }
        // Path B is a field encounter, so the creator owns it; Path A goes to the region's BM.
        if ("B".equalsIgnoreCase(body.creationPath)) {
            lead.setAssignTo(me.getId());
        }

        // THE point of this endpoint: region -> owner -> stage -> 5hr SLA, per SOP 9.
        LmsAssignmentService.Assignment assignment = lmsAssignmentService.assign(lead, body.creationPath);
        leadRepository.persist(lead);

        // lms_code needs the generated id, so it can only be stamped after the insert.
        lead.setLmsCode(lmsAssignmentService.generateLmsCode(lead));
        leadRepository.persist(lead);

        boolean hasBrands = body.leadBrands != null && !body.leadBrands.isEmpty();
        if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands) {
            try {
                LeadDetailModel detail = new LeadDetailModel();
                detail.setLeadId(lead.getId());
                detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());
                if (hasBrands) {
                    List<LeadBrandModel> brands = new ArrayList<>();
                    for (BrandValue bv : body.leadBrands) {
                        if (bv == null || bv.brand == null || bv.brand.trim().isEmpty() || bv.value == null
                                || bv.value <= 0) {
                            // Only brands the retailer actually stocks. Persisting zeroes for the rest
                            // is what made the legacy brand table useless for reporting.
                            continue;
                        }
                        LeadBrandModel brand = new LeadBrandModel();
                        brand.setBrand(bv.brand.trim());
                        brand.setValue(bv.value.intValue());
                        brands.add(brand);
                    }
                    detail.setLeadBrands(brands);
                }
                leadRepository.persistLeadDetail(detail, me);
            } catch (Exception e) {
                // Shop name and brand split are not worth losing an otherwise-good lead over.
                LOGGER.warn("Could not save the lead detail for lead {}", lead.getId(), e);
            }
        }

        StringBuilder created = new StringBuilder("Lead created from the LMS dashboard");
        if (hasBrands) {
            created.append(" · brand-wise value: ");
            boolean first = true;
            for (BrandValue bv : body.leadBrands) {
                if (bv == null || bv.brand == null || bv.value == null || bv.value <= 0) {
                    continue;
                }
                if (!first) {
                    created.append(", ");
                }
                created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
                first = false;
            }
        }
        trail(lead.getId(), me.getId(), created.toString()
                + (assignment != null && "HOLD".equals(assignment.assignmentStatus)
                   ? " — region has no active BM/RSM, parked in the HOLD queue" : ""));

        // Auto-generate the geo capture link so the agent can send it during the same call.
        String geoLink = buildGeoCaptureLink(lead.getId());
        if (geoLink != null) {
            trail(lead.getId(), me.getId(), "Geolocation link generated for lead");
        }

        Map<String, Object> out = new LinkedHashMap<>();
        out.put("ok", true);
        out.put("leadId", lead.getId());
        out.put("lmsCode", lead.getLmsCode());
        out.put("assignmentStatus", lead.getAssignmentStatus());
        out.put("ownerName", assignment != null && assignment.bm != null
                ? assignment.bm.getFirstName() + " " + assignment.bm.getLastName() : null);
        out.put("geoLink", geoLink);
        LOGGER.info("LMS lead {} created ({}) by auth {} — region {} status {}",
                lead.getId(), lead.getLmsCode(), me.getId(), lead.getRegionCode(), lead.getAssignmentStatus());
        return responseSender.ok(out);
    }

    /** Append a trail entry. Never fatal — a lost note must not fail the lead. */
    private void trail(int leadId, int authId, String remark) {
        try {
            LeadActivity activity = new LeadActivity();
            activity.setLeadId(leadId);
            activity.setAuthId(authId);
            activity.setRemark(remark);
            activity.setCreatedTimestamp(LocalDateTime.now());
            leadActivityRepository.persist(activity);
        } catch (Exception e) {
            LOGGER.warn("Could not write the trail entry for lead {}", leadId, e);
        }
    }

    /**
     * {base}/lead-geo/{leadId} — the same URL the Leads screen's Generate Link button produces.
     * Null when the base URL is unconfigured, which must not stop a lead being created.
     */
    private String buildGeoCaptureLink(int leadId) {
        String base = leadGeoPublicBaseUrl;
        if (base == null || base.trim().isEmpty()) {
            return null;
        }
        base = base.trim();
        while (base.endsWith("/")) {
            base = base.substring(0, base.length() - 1);
        }
        return base + "/lead-geo/" + leadId;
    }

    /** Payload for {@link #createLead}. */
    public static class CreateLeadRequest {
        public String firstName;      // retailer contact name, split into first/last
        public String mobile;
        public String outletName;     // business / shop name
        public String source;
        public Integer regionId;      // drives auto-assignment
        public String creationPath;   // A = BGC outbound, B = field encounter
        public Double potential;      // monthly business value — the sum of leadBrands
        /** Per-brand monthly business. What a BM actually needs to size the counter. */
        public List<BrandValue> leadBrands;
    }

    /** One brand's monthly business value. */
    public static class BrandValue {
        public String brand;
        public Double value;
    }
}