package com.clanstop.healing.locator;

import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Weighted similarity between a stored fingerprint and a candidate element, in [0..1].
 *
 * <p>The score is normalised by the total weight of signals actually <em>present</em> in the
 * stored fingerprint, so a sparse element (no aria-label, no name) is not penalised for the
 * signals it never had. A tag mismatch is a hard cap: a {@code <div>} can never fully heal to
 * a {@code <button>}.
 */
public final class SimilarityScorer {

    private static final double W_ID       = 0.30;
    private static final double W_TAG      = 0.10;
    private static final double W_NAME     = 0.12;
    private static final double W_TEXT     = 0.18;
    private static final double W_CLASSES  = 0.10;
    private static final double W_ATTRS    = 0.10;
    private static final double W_POSITION = 0.05;
    private static final double W_DEPTH    = 0.05;

    private static final double TAG_MISMATCH_CAP = 0.5;
    private static final double VIEWPORT_DIAGONAL = 1600; // rough px scale for position falloff

    private final Pattern volatileId;

    public SimilarityScorer(String volatileIdRegex) {
        this.volatileId = Pattern.compile(volatileIdRegex);
    }

    public double score(Fingerprint stored, Fingerprint candidate) {
        double weightPresent = 0;
        double weighted = 0;

        // Stable id (skip auto-generated ids that will differ every render).
        if (notBlank(stored.id()) && !volatileId.matcher(stored.id()).matches()) {
            weightPresent += W_ID;
            weighted += W_ID * (stored.id().equals(candidate.id()) ? 1 : 0);
        }
        // data-testid is the strongest stable hook when present.
        String storedTestId = stored.attrs().get("data-testid");
        if (notBlank(storedTestId)) {
            weightPresent += W_ID;
            weighted += W_ID * (storedTestId.equals(candidate.attrs().get("data-testid")) ? 1 : 0);
        }

        // Tag (always present).
        boolean tagMatch = stored.tag().equalsIgnoreCase(candidate.tag());
        weightPresent += W_TAG;
        weighted += W_TAG * (tagMatch ? 1 : 0);

        if (notBlank(stored.name())) {
            weightPresent += W_NAME;
            weighted += W_NAME * (stored.name().equals(candidate.name()) ? 1 : 0);
        }
        if (notBlank(stored.visibleText())) {
            weightPresent += W_TEXT;
            weighted += W_TEXT * levenshteinRatio(stored.visibleText(), candidate.visibleText());
        }
        if (stored.classList() != null && !stored.classList().isEmpty()) {
            weightPresent += W_CLASSES;
            weighted += W_CLASSES * jaccard(stored.classList(), candidate.classList());
        }
        double attrScore = attrScore(stored, candidate);
        if (attrScore >= 0) {
            weightPresent += W_ATTRS;
            weighted += W_ATTRS * attrScore;
        }

        // Position and depth are always available (cheap tie-breakers).
        weightPresent += W_POSITION;
        double dist = Math.hypot(stored.centerX() - candidate.centerX(),
                stored.centerY() - candidate.centerY());
        weighted += W_POSITION * (1 - Math.min(1, dist / VIEWPORT_DIAGONAL));

        weightPresent += W_DEPTH;
        weighted += W_DEPTH * (1 - Math.min(1, Math.abs(stored.domDepth() - candidate.domDepth()) / 6.0));

        double score = weightPresent == 0 ? 0 : weighted / weightPresent;
        if (!tagMatch) {
            score = Math.min(score, TAG_MISMATCH_CAP);
        }
        return score;
    }

    /** Average exact-match over the tracked attrs present in the stored fingerprint. -1 if none. */
    private static double attrScore(Fingerprint stored, Fingerprint candidate) {
        int present = 0, matched = 0;
        for (String key : Fingerprint.TRACKED_ATTRS) {
            if (key.equals("data-testid")) {
                continue; // already weighted as a stable id above
            }
            String sv = stored.attrs().get(key);
            if (notBlank(sv)) {
                present++;
                if (sv.equals(candidate.attrs().get(key))) {
                    matched++;
                }
            }
        }
        return present == 0 ? -1 : (double) matched / present;
    }

    private static double jaccard(java.util.List<String> a, java.util.List<String> b) {
        if (a.isEmpty() && b.isEmpty()) {
            return 1;
        }
        Set<String> union = new HashSet<>(a);
        union.addAll(b);
        Set<String> inter = new HashSet<>(a);
        inter.retainAll(new HashSet<>(b));
        return union.isEmpty() ? 0 : (double) inter.size() / union.size();
    }

    /** Normalised Levenshtein similarity in [0..1]. */
    static double levenshteinRatio(String a, String b) {
        if (a.equals(b)) {
            return 1;
        }
        int maxLen = Math.max(a.length(), b.length());
        if (maxLen == 0) {
            return 1;
        }
        return 1.0 - (double) levenshtein(a, b) / maxLen;
    }

    private static int levenshtein(String a, String b) {
        int[] prev = new int[b.length() + 1];
        int[] curr = new int[b.length() + 1];
        for (int j = 0; j <= b.length(); j++) {
            prev[j] = j;
        }
        for (int i = 1; i <= a.length(); i++) {
            curr[0] = i;
            for (int j = 1; j <= b.length(); j++) {
                int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
                curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
            }
            int[] tmp = prev;
            prev = curr;
            curr = tmp;
        }
        return prev[b.length()];
    }

    private static boolean notBlank(String s) {
        return s != null && !s.isBlank();
    }
}
