package vip.mate.wiki.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Single source of truth for wikilink extraction and resolution-state
* computation. The page viewer's TypeScript {@code resolveWikilink} mirrors
* the matching semantics; both must stay in lockstep so users do not see a
* visibly-working link that the lint marks as broken (or vice-versa).
*
* Resolution rule (intentionally narrow):
*
*
* - Extract every {@code [[target]]} or {@code [[target|display]]} from
* content, skipping fenced and inline code spans.
* - For each occurrence keep only {@code target.toLowerCase().trim()} —
* no {@link WikiPageService#canonicalSlug} fuzzy collapse, no
* title→slug guessing. The lint flags a link as broken iff no active
* KB page has {@code page.slug.equalsIgnoreCase(target)}.
*
*
* The strict comparison surfaces real authoring mistakes (typo in slug,
* stale ref to a renamed page) rather than silently papering over them with
* canonical-form coercion. Phase 1's frontend resolver keeps a title
* fallback for legacy {@code [[Page Title]]} content so the visible link
* still navigates, but that fallback is intentionally absent here — title-
* form authors are expected to migrate as the slug-first prompt rollout
* lands in Phase 3.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiLinkService {
/**
* Matches every {@code [[...]]} occurrence. Non-greedy on the inside so
* pathological inputs like {@code [[a]] [[b]]} resolve as two separate
* links rather than one giant link {@code "a]] [[b"}.
*/
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]");
/**
* Matches a fenced code block. Anchored to {@code ^```} on a line so a
* stray triple-backtick mid-paragraph does not flip the world into "in
* code" mode and swallow real wikilinks for the rest of the document.
* Captures the opening fence and content lazily; the matched range is
* removed wholesale before wikilink extraction.
*/
private static final Pattern FENCED_CODE = Pattern.compile(
"(?m)^```[\\s\\S]*?^```", Pattern.MULTILINE);
/**
* Matches inline {@code `...`} spans. Non-greedy so adjacent inline spans
* are handled as separate matches.
*/
private static final Pattern INLINE_CODE = Pattern.compile("`[^`\\n]*?`");
/** Hard cap matching the frontend's MAX_SLUG_LEN — see wikilink.ts. */
private static final int MAX_TARGET_LEN = 256;
private final ObjectMapper objectMapper;
/**
* Extract every wikilink target string from {@code content}, normalised
* to lowercase + trimmed, with code blocks stripped first.
*
* Returns an insertion-ordered set so callers that serialize to JSON get
* a stable order (helps diffability of {@code broken_links} fields across
* scans and makes audit logs easier to read).
*
* @param content full markdown body; {@code null} or blank returns empty
* @return targets as written (before {@code |} alias), lowercased
*/
public Set extractOutlinks(String content) {
if (content == null || content.isBlank()) return Collections.emptySet();
// Strip code first so inline / fenced examples that show literal
// [[wikilink]] syntax stay literal. Replacement with an equal-length
// run of spaces would be more correct (preserves positions for any
// future error reporting) but isn't worth the complexity here — we
// only need the targets.
String stripped = FENCED_CODE.matcher(content).replaceAll("");
stripped = INLINE_CODE.matcher(stripped).replaceAll("");
Set targets = new LinkedHashSet<>();
Matcher m = WIKILINK.matcher(stripped);
while (m.find()) {
String raw = m.group(1).trim();
if (raw.isEmpty()) continue;
int pipe = raw.indexOf('|');
String target = (pipe >= 0 ? raw.substring(0, pipe) : raw).trim();
if (target.isEmpty() || target.length() > MAX_TARGET_LEN) continue;
// Lowercase here so {@link #computeBrokenLinks} can do exact
// equality against {@code page.slug.toLowerCase()} without an
// extra normalisation step per page.
targets.add(target.toLowerCase(Locale.ROOT));
}
return targets;
}
/**
* Compute the broken subset of {@code outlinks} given the KB's active
* page slug set. {@code activeSlugs} is expected to be already lowercased
* — callers compute it once per scan and reuse across pages.
*
* @return targets that have no matching page slug, in the same insertion
* order as {@code outlinks}
*/
public List computeBrokenLinks(Set outlinks, Set activeSlugsLower) {
if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList();
if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet();
List broken = new ArrayList<>();
for (String t : outlinks) {
if (!activeSlugsLower.contains(t)) broken.add(t);
}
return broken;
}
/**
* Convenience: extract + compute in one call. Used from
* {@code WikiPageService.save/update} where both fields are written in
* the same transaction.
*/
public LinkAnalysis analyze(String content, Set activeSlugsLower) {
Set outlinks = extractOutlinks(content);
List broken = computeBrokenLinks(outlinks, activeSlugsLower);
return new LinkAnalysis(new ArrayList<>(outlinks), broken);
}
/** Pair returned by {@link #analyze(String, Set)}. */
public record LinkAnalysis(List outgoingLinks, List brokenLinks) {}
/** Serialize a list to JSON for persistence. Best-effort: never throws. */
public String toJsonArray(List values) {
if (values == null || values.isEmpty()) return "[]";
try {
return objectMapper.writeValueAsString(values);
} catch (Exception e) {
log.warn("[WikiLink] Failed to serialize list to JSON, falling back to empty: {}", e.getMessage());
return "[]";
}
}
/** Parse a JSON array back into a list. Best-effort: never throws. */
public List fromJsonArray(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {
return objectMapper.readValue(json, new TypeReference>() {});
} catch (Exception e) {
log.warn("[WikiLink] Failed to parse JSON array, treating as empty: {}", e.getMessage());
return Collections.emptyList();
}
}
/**
* Compute the lowercase slug set for a KB from a pre-loaded page list.
* Centralised so both single-page save paths and the KB-wide scan use the
* same definition of "active page".
*/
public Set lowercaseSlugSet(List pages) {
if (pages == null || pages.isEmpty()) return Collections.emptySet();
return pages.stream()
.map(WikiPageEntity::getSlug)
.filter(s -> s != null && !s.isBlank())
.map(s -> s.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
}
// ============================================================
// Cascade rewrite — used by page delete + rename to update referrers
// ============================================================
/**
* Strip every {@code [[deletedSlug]]} or {@code [[deletedSlug|alias]]}
* occurrence in {@code content}, replacing the wikilink with plain text:
*
*
* - {@code [[deletedSlug]]} → {@code snapshotDisplay} (the deleted
* page's last known title, or the slug itself if title missing)
* - {@code [[deletedSlug|alias]]} → {@code alias} (the author's
* chosen display text wins)
*
*
* Mirrors {@link #extractOutlinks} on every protective axis: code blocks
* are skipped via the same fenced/inline strip-and-restore dance below,
* matching is exact case-insensitive on the slug only (never on the
* alias), and at-most-one {@code |} is honoured so a malformed
* {@code [[a|b|c]]} keeps the b|c suffix as alias text rather than
* collapsing.
*/
public String stripDeletedLink(String content, String deletedSlug, String snapshotDisplay) {
if (content == null || content.isEmpty()) return content;
if (deletedSlug == null || deletedSlug.isBlank()) return content;
String targetLower = deletedSlug.toLowerCase(Locale.ROOT);
String fallback = (snapshotDisplay != null && !snapshotDisplay.isBlank())
? snapshotDisplay : deletedSlug;
return rewriteWikilinks(content, (slugLower, alias) -> {
if (!slugLower.equals(targetLower)) return null; // unchanged
// No href to preserve — the link is being demoted to plain text.
return (alias != null && !alias.isBlank()) ? alias : fallback;
});
}
/**
* Rewrite every {@code [[oldSlug]]} or {@code [[oldSlug|alias]]} so the
* target becomes {@code newSlug}. Preserves the wikilink form — only the
* slug part changes, the alias (if any) is kept verbatim. Used when a
* page is renamed and every referrer must follow.
*/
public String renameLink(String content, String oldSlug, String newSlug) {
if (content == null || content.isEmpty()) return content;
if (oldSlug == null || oldSlug.isBlank() || newSlug == null || newSlug.isBlank()) return content;
String oldLower = oldSlug.toLowerCase(Locale.ROOT);
return rewriteWikilinks(content, (slugLower, alias) -> {
if (!slugLower.equals(oldLower)) return null;
// Return the full replacement string for this wikilink occurrence
// (still a wikilink, just with a different target).
return (alias != null && !alias.isBlank())
? "[[" + newSlug + "|" + alias + "]]"
: "[[" + newSlug + "]]";
});
}
/**
* Walk {@code content} replacing wikilinks via {@code rewriter}. Code
* spans are detected and restored verbatim — replacement only happens in
* "narrative" regions so a doc literally showing {@code [[foo]]} inside
* a code fence is never silently mutated.
*
* The rewriter receives the lowercased slug and the raw alias (or
* {@code null}). It returns either:
*
* - {@code null} to leave the wikilink unchanged (caller is not
* interested in this slug), or
* - the literal replacement string — typically plain text for a
* strip, or a re-formed {@code [[newSlug]]} for a rename.
*
*/
private String rewriteWikilinks(String content,
java.util.function.BiFunction rewriter) {
// Split content into alternating "narrative" and "code" regions so we
// can apply the rewriter only to narrative. The same fenced+inline
// patterns the extractor uses, but here we preserve the matched code
// text verbatim instead of stripping it.
List regions = splitByCode(content);
StringBuilder out = new StringBuilder(content.length() + 16);
for (Region r : regions) {
if (r.isCode) {
out.append(r.text);
continue;
}
Matcher m = WIKILINK.matcher(r.text);
int last = 0;
while (m.find()) {
out.append(r.text, last, m.start());
String raw = m.group(1).trim();
String target;
String alias;
int pipe = raw.indexOf('|');
if (pipe >= 0) {
target = raw.substring(0, pipe).trim();
alias = raw.substring(pipe + 1).trim();
} else {
target = raw;
alias = null;
}
String replacement = null;
if (!target.isEmpty()) {
replacement = rewriter.apply(target.toLowerCase(Locale.ROOT), alias);
}
if (replacement == null) {
out.append(m.group());
} else {
out.append(replacement);
}
last = m.end();
}
out.append(r.text, last, r.text.length());
}
return out.toString();
}
/** Linear scan that splits content into alternating narrative + code regions. */
private List splitByCode(String content) {
List result = new ArrayList<>();
if (content == null || content.isEmpty()) return result;
// Run fenced first, then inline within each non-code piece.
List afterFenced = splitOne(content, FENCED_CODE);
for (Region r : afterFenced) {
if (r.isCode) { result.add(r); continue; }
result.addAll(splitOne(r.text, INLINE_CODE));
}
return result;
}
private List splitOne(String text, Pattern codePattern) {
List out = new ArrayList<>();
Matcher m = codePattern.matcher(text);
int last = 0;
while (m.find()) {
if (m.start() > last) out.add(new Region(text.substring(last, m.start()), false));
out.add(new Region(m.group(), true));
last = m.end();
}
if (last < text.length()) out.add(new Region(text.substring(last), false));
return out;
}
/** Narrative-vs-code text region used by {@link #rewriteWikilinks}. */
private record Region(String text, boolean isCode) {}
}