'use strict';

const FRAME_SCHEMA_VERSION = 1;
const MAX_CONTEXT_MESSAGES = 60;
const MAX_RECOMMENDATION_NAMES = 12;

const DOMAIN_PATTERNS = [
  ['email', /\b(e-?mail|inbox|reply|respond|thread|recipient|cc|bcc|gmail|mail)\b/i],
  ['calendar', /\b(calendar|meeting|schedule|appointment|event|availability|free|busy)\b/i],
  ['coding', /\b(code|repo|repository|branch|commit|merge|test|build|bug|fix|implement|refactor|deploy|website|app|ui|ux)\b/i],
  ['travel', /\b(trip|travel|flight|hotel|lodging|airbnb|vrbo|stay|rental car|car rental|airport|cruise|booking|reservation|itinerary|destination)\b/i],
  ['recommendation', /\b(recommend|recommendation|top\s+\d+|best|rank|choose|pick|options?)\b/i],
];

const TRAVEL_RE = /\b(trip|travel|flight|hotel|lodging|airbnb|vrbo|stay|rental car|car rental|airport|cruise|booking|reservation|itinerary|destination)\b/i;
const RECOMMENDATION_RE = /\b(recommend|recommendation|top\s+\d+|best|rank|choose|pick|options?)\b/i;
const LODGING_RE = /\b(hotel|lodging|airbnb|vrbo|vacation rental|short[- ]term rental|stay|staying|room|resort|inn|booking|book directly|direct booking)\b/i;
const TRANSPORT_RE = /\b(flight|airport|rental car|car rental|drop ?off|pick ?up|drive|driving|terminal)\b/i;
const CORRECTION_RE = /\b(doesn'?t change|does not change|same|unchanged|wrong|not what|lost|different|instead|rather than|not just|not limited|doesn'?t have to be|can be|go back|stick with|you missed|you ignored)\b/i;

const STOP_LOCATION_WORDS = new Set([
  'Given', 'Give', 'Can', 'Should', 'What', 'When', 'Where', 'Why', 'How', 'The',
  'This', 'That', 'There', 'Hotel', 'Hotels', 'Airbnb', 'Vrbo', 'Dec', 'Jan',
  'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Sept', 'Oct', 'Nov',
  'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
  'Plan', 'Help', 'Recommend', 'Recommendation', 'Recommendations', 'Top', 'Book',
  'Booking', 'Direct', 'New',
]);

function parseSessionMetadata(raw) {
  if (!raw) return {};
  if (typeof raw === 'object') return raw;
  try {
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' ? parsed : {};
  } catch {
    return {};
  }
}

function normalizeSemanticMessages(messages, currentMessage = '') {
  const raw = Array.isArray(messages) ? messages : [];
  const normalized = [];
  for (const item of raw) {
    if (!item || typeof item !== 'object') continue;
    const role = String(item.role || '').trim();
    if (role !== 'user' && role !== 'assistant') continue;
    const content = contentToText(item.content != null ? item.content : item.text);
    if (!content.trim()) continue;
    normalized.push({ role, content: content.trim() });
  }
  const current = String(currentMessage || '').trim();
  if (current) {
    const last = normalized[normalized.length - 1];
    if (!last || last.role !== 'user' || last.content !== current) {
      normalized.push({ role: 'user', content: current });
    } else {
      last.content = current;
    }
  }
  return normalized.slice(-MAX_CONTEXT_MESSAGES);
}

function contentToText(content) {
  if (content == null) return '';
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    return content.map((part) => {
      if (!part) return '';
      if (typeof part === 'string') return part;
      if (part.type === 'text') return part.text || '';
      if (part.type === 'tool_result') return part.content || '';
      if (part.type === 'tool_use') return `[tool:${part.name || 'unknown'}]`;
      return '';
    }).filter(Boolean).join('\n');
  }
  try { return JSON.stringify(content); } catch { return String(content); }
}

function buildConversationRuntime({ messages, currentMessage, previousFrame, channel, cwd, taskType } = {}) {
  const semanticMessages = normalizeSemanticMessages(messages, currentMessage);
  const frame = deriveConversationFrame(semanticMessages, {
    previousFrame,
    channel,
    cwd,
    taskType,
  });
  return {
    schemaVersion: FRAME_SCHEMA_VERSION,
    messages: semanticMessages,
    frame,
    validators: validatorsForFrame(frame),
  };
}

function deriveConversationFrame(messages, opts = {}) {
  const previous = normalizeFrame(opts.previousFrame);
  const currentUser = [...messages].reverse().find((msg) => msg.role === 'user')?.content || '';
  const allText = messages.map((msg) => msg.content).join('\n');
  const currentDomain = inferDomain(currentUser) || previous.domain || inferDomain(allText) || 'general';
  const isCorrection = CORRECTION_RE.test(currentUser);
  const now = new Date().toISOString();

  const frame = {
    schemaVersion: FRAME_SCHEMA_VERSION,
    updatedAt: now,
    domain: currentDomain,
    activeGoal: inferActiveGoal(currentUser, previous.activeGoal),
    scope: {
      ...(previous.scope || {}),
      ...(opts.cwd ? { projectPath: opts.cwd } : {}),
    },
    constraints: [...(previous.constraints || [])],
    recentCorrections: [...(previous.recentCorrections || [])],
    inactiveRelatedFrames: [...(previous.inactiveRelatedFrames || [])],
    evidenceRefs: [],
    previousRecommendations: Array.isArray(previous.previousRecommendations)
      ? previous.previousRecommendations.slice(0, MAX_RECOMMENDATION_NAMES)
      : [],
    confidence: previous.confidence || 0.45,
  };

  const travelLike = currentDomain === 'travel' || TRAVEL_RE.test(allText) || RECOMMENDATION_RE.test(currentUser);
  if (travelLike) applyTravelFrame(frame, messages, currentUser, isCorrection);
  applyActionFrame(frame, messages, currentUser);

  const constraints = inferConstraints(currentUser);
  for (const constraint of constraints) addUnique(frame.constraints, constraint);
  if (isCorrection) addRecentCorrection(frame, currentUser);

  const explicitDates = extractDates(allText);
  if (explicitDates.length) frame.scope.dates = explicitDates.slice(-4);
  const travelerSummary = extractTravelerSummary(allText);
  if (travelerSummary) frame.scope.travelers = travelerSummary;

  frame.previousRecommendations = collectPreviousRecommendations(messages)
    .concat(frame.previousRecommendations || [])
    .filter(uniqueByLower)
    .slice(0, MAX_RECOMMENDATION_NAMES);

  frame.confidence = scoreFrameConfidence(frame);
  return pruneFrame(frame);
}

function normalizeFrame(frame) {
  if (!frame || typeof frame !== 'object') return {};
  return {
    schemaVersion: frame.schemaVersion || FRAME_SCHEMA_VERSION,
    domain: typeof frame.domain === 'string' ? frame.domain : undefined,
    activeGoal: typeof frame.activeGoal === 'string' ? frame.activeGoal : undefined,
    scope: frame.scope && typeof frame.scope === 'object' ? { ...frame.scope } : {},
    constraints: Array.isArray(frame.constraints) ? frame.constraints.filter(Boolean).map(String) : [],
    recentCorrections: Array.isArray(frame.recentCorrections) ? frame.recentCorrections.filter(Boolean).map(String).slice(-5) : [],
    inactiveRelatedFrames: Array.isArray(frame.inactiveRelatedFrames) ? frame.inactiveRelatedFrames.filter(Boolean).map(String).slice(-8) : [],
    evidenceRefs: Array.isArray(frame.evidenceRefs) ? frame.evidenceRefs : [],
    previousRecommendations: Array.isArray(frame.previousRecommendations) ? frame.previousRecommendations.filter(Boolean).map(String) : [],
    confidence: Number(frame.confidence || 0) || undefined,
  };
}

function inferDomain(text) {
  for (const [domain, re] of DOMAIN_PATTERNS) {
    if (re.test(text || '')) return domain;
  }
  return null;
}

function inferActiveGoal(text, fallback = '') {
  const value = String(text || '');
  if (/\b(reply|respond)\b/i.test(value) && /\b(email|mail|thread)\b/i.test(value)) return 'reply to email thread';
  if (/\b(send|draft|write)\b/i.test(value) && /\b(email|message)\b/i.test(value)) return 'draft or send outbound message';
  if (/\b(calendar|schedule|create.*event|add.*event)\b/i.test(value)) return 'schedule calendar event';
  if (/\b(hotel|lodging|airbnb|vrbo|stay)\b/i.test(value) && RECOMMENDATION_RE.test(value)) return 'recommend lodging options';
  if (TRAVEL_RE.test(value)) return 'plan travel';
  if (/\b(fix|implement|build|debug|refactor|test)\b/i.test(value)) return 'complete coding task';
  if (RECOMMENDATION_RE.test(value)) return 'recommend options';
  return fallback || 'answer current user request';
}

function applyTravelFrame(frame, messages, currentUser, isCorrection) {
  const latestLodgingLocation = findLatestLocationForContext(messages, LODGING_RE);
  if (latestLodgingLocation) frame.scope.lodgingLocation = latestLodgingLocation;

  const latestTravelDestination = findLatestTravelDestination(messages);
  if (latestTravelDestination && !frame.scope.destination) frame.scope.destination = latestTravelDestination;

  const inactiveLocations = findInactiveTravelLocations(messages, frame.scope.lodgingLocation || frame.scope.destination);
  for (const loc of inactiveLocations) addUnique(frame.inactiveRelatedFrames, loc);

  if (/\bdirect(?:ly)?\b/i.test(currentUser) && /\b(book|booking|hotel|lodging|reservation)\b/i.test(currentUser)) {
    addUnique(frame.constraints, 'booking_channel:direct');
  }
  if (/\bairbnb|vrbo|vacation rental|short[- ]term rental|home rental|condo\b/i.test(currentUser)) {
    addUnique(frame.constraints, 'include_supply_type:alternative_rentals');
  }
  if (/\bnot\s+(?:just|only|limited to)\s+hilton|doesn'?t have to be hilton|not hilton-only\b/i.test(currentUser)) {
    addUnique(frame.constraints, 'not_brand_limited:hilton');
  }
  if (/\b(new|different|changed|doesn'?t change|same)\b/i.test(currentUser) && RECOMMENDATION_RE.test(currentUser + ' recommendation')) {
    addUnique(frame.constraints, 'requires_novel_recommendations');
  }
  if (isCorrection) addUnique(frame.constraints, 'latest_user_correction_wins');
}

function applyActionFrame(frame, messages, currentUser) {
  if (frame.domain === 'email') {
    if (/\b(reply|respond|original email|same thread)\b/i.test(currentUser)) {
      addUnique(frame.constraints, 'email_action:reply_to_original_thread');
    }
    const recipients = extractEmails(messages.map(m => m.content).join('\n'));
    if (recipients.length) frame.scope.recipients = recipients.slice(-8);
  }
  if (frame.domain === 'calendar') {
    const calendars = [];
    if (/\bwork\b|examplecorp/i.test(currentUser)) calendars.push('work');
    if (/\bpersonal\b|gmail/i.test(currentUser)) calendars.push('personal');
    if (calendars.length) frame.scope.calendars = calendars;
  }
  if (frame.domain === 'coding' && !frame.scope.projectPath) {
    const pathMatch = messages.map(m => m.content).join('\n').match(/(?:cwd|worktree|repo|project)\s*[:=]\s*(`?)(\/[^\s`]+)\1/i);
    if (pathMatch) frame.scope.projectPath = pathMatch[2];
  }
}

function inferConstraints(text) {
  const value = String(text || '');
  const constraints = [];
  if (/\bno approval needed|just do it|go ahead\b/i.test(value)) constraints.push('user_authorized_execution');
  if (/\bdon'?t send\b|\bdo not send\b/i.test(value)) constraints.push('external_action:draft_only');
  if (/\btest (it|your changes)|validate|verify\b/i.test(value)) constraints.push('verification_required');
  return constraints;
}

function findLatestLocationForContext(messages, contextRe) {
  const ordered = [
    ...[...messages].filter(msg => msg.role === 'user').reverse(),
    ...[...messages].filter(msg => msg.role !== 'user').reverse(),
  ];
  for (const msg of ordered) {
    const text = msg.content || '';
    if (!contextRe.test(text) && !TRAVEL_RE.test(text)) continue;
    const explicit = findPrepositionalLocation(text);
    if (explicit) return explicit;
    const locations = extractLocationCandidates(text);
    const contextual = locations.find((loc) => appearsNearContext(text, loc, contextRe));
    if (contextual) return contextual;
  }
  return '';
}

function findLatestTravelDestination(messages) {
  const ordered = [
    ...[...messages].filter(msg => msg.role === 'user').reverse(),
    ...[...messages].filter(msg => msg.role !== 'user').reverse(),
  ];
  for (const msg of ordered) {
    const text = msg.content || '';
    if (!TRAVEL_RE.test(text) && !RECOMMENDATION_RE.test(text)) continue;
    const explicit = findPrepositionalLocation(text);
    if (explicit) return explicit;
    const locations = extractLocationCandidates(text);
    if (locations.length) return locations[0];
  }
  return '';
}

function findInactiveTravelLocations(messages, activeLocation) {
  const active = normalizeComparable(activeLocation);
  const out = [];
  for (const msg of messages) {
    const text = msg.content || '';
    if (!TRANSPORT_RE.test(text)) continue;
    for (const loc of extractLocationCandidates(text)) {
      if (normalizeComparable(loc) && normalizeComparable(loc) !== active) addUnique(out, loc);
    }
  }
  return out.slice(-6);
}

function extractLocationCandidates(text) {
  const value = stripUrls(String(text || ''));
  const out = [];
  const airportRe = /\b[A-Z]{3}\b/g;
  for (const match of value.matchAll(airportRe)) addUnique(out, match[0]);

  const properRe = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\b/g;
  for (const match of value.matchAll(properRe)) {
    const candidate = match[0].trim();
    const first = candidate.split(/\s+/)[0];
    if (STOP_LOCATION_WORDS.has(first)) continue;
    if (candidate.length < 4 || candidate.length > 60) continue;
    if (/^(Wall|You|Today|Thanks|Please|Hi|Hello|Best|Review|Summary|Final|Action|Status)$/i.test(candidate)) continue;
    addUnique(out, candidate);
  }
  return out;
}

function findPrepositionalLocation(text) {
  const value = stripUrls(String(text || ''));
  const patterns = [
    /\b(?:in|near|around|for|to)\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){0,3}|[A-Z]{3})\b(?=[^\n]{0,80}\b(?:hotel|lodging|airbnb|vrbo|stay|recommend|trip|travel|booking|reservation)\b)/i,
    /\b(?:hotel|lodging|airbnb|vrbo|stay|recommend|trip|travel|booking|reservation)\b[^\n]{0,80}\b(?:in|near|around|for|to)\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){0,3}|[A-Z]{3})\b/i,
    /\b([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){0,3}|[A-Z]{3})\s+(?:hotel|hotels|lodging|airbnb|vrbo|stay|trip|travel|booking|reservation|recommendations?)\b/i,
  ];
  for (const pattern of patterns) {
    const match = value.match(pattern);
    if (match?.[1]) {
      const location = cleanLocation(match[1]);
      if (isLikelyLocationCandidate(location)) return location;
    }
  }
  return '';
}

function appearsNearContext(text, location, contextRe) {
  if (!location) return false;
  const value = String(text || '');
  const idx = value.toLowerCase().indexOf(location.toLowerCase());
  if (idx < 0) return false;
  const window = value.slice(Math.max(0, idx - 100), Math.min(value.length, idx + location.length + 100));
  return contextRe.test(window);
}

function cleanLocation(value) {
  const clean = String(value || '')
    .replace(/[.,;:!?)}\]]+$/g, '')
    .trim();
  const parts = clean.split(/\s+/).filter(Boolean);
  while (parts.length > 1 && STOP_LOCATION_WORDS.has(parts[0])) parts.shift();
  if (parts.length === 1 && STOP_LOCATION_WORDS.has(parts[0])) return '';
  return parts.join(' ');
}

function isLikelyLocationCandidate(value) {
  const clean = String(value || '').trim();
  if (!clean) return false;
  if (/^[A-Z]{3}$/.test(clean)) return true;
  return /^[A-Z]/.test(clean) && !STOP_LOCATION_WORDS.has(clean.split(/\s+/)[0]);
}

function stripUrls(value) {
  return String(value || '').replace(/https?:\/\/\S+/g, ' ');
}

function extractDates(text) {
  const value = String(text || '');
  const out = [];
  for (const match of value.matchAll(/\b20\d{2}-\d{2}-\d{2}\b/g)) addUnique(out, match[0]);
  for (const match of value.matchAll(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}(?:\s*(?:-|to|through|–)\s*(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s*)?\d{1,2})?(?:,\s*20\d{2})?\b/gi)) {
    addUnique(out, match[0]);
  }
  return out.slice(-8);
}

function extractTravelerSummary(text) {
  const value = String(text || '');
  const parts = [];
  const adults = value.match(/\b(\d+)\s+adults?\b/i);
  if (adults) parts.push(`${adults[1]} adult${adults[1] === '1' ? '' : 's'}`);
  const children = value.match(/\b(\d+)\s+(?:kids?|children|child)\b/i);
  if (children) parts.push(`${children[1]} child${children[1] === '1' ? '' : 'ren'}`);
  const age = value.match(/\b(\d{1,2})\s*years?\s*old\b/i);
  if (age && !children) parts.push(`${age[1]}-year-old traveler`);
  return parts.join(' + ');
}

function extractEmails(text) {
  return [...new Set(String(text || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [])];
}

function collectPreviousRecommendations(messages) {
  const names = [];
  for (const msg of messages) {
    if (msg.role !== 'assistant') continue;
    for (const name of extractRecommendationNames(msg.content)) addUnique(names, name);
  }
  return names.slice(-MAX_RECOMMENDATION_NAMES);
}

function extractRecommendationNames(text) {
  const out = [];
  const value = String(text || '');
  for (const match of value.matchAll(/\*\*([^*\n]{3,80})\*\*/g)) {
    const name = cleanRecommendationName(match[1]);
    if (name) addUnique(out, name);
  }
  for (const line of value.split('\n')) {
    const match = line.match(/^\s*(?:[-*]|\d+[.)])\s+(?:\*\*)?([^:*—-][^:—\n]{2,80}?)(?:\*\*)?(?:\s*[:—-]|\s+\(|$)/);
    if (!match) continue;
    const name = cleanRecommendationName(match[1]);
    if (name) addUnique(out, name);
  }
  return out.slice(0, MAX_RECOMMENDATION_NAMES);
}

function cleanRecommendationName(value) {
  const name = String(value || '')
    .replace(/^[^\wA-Z]+/, '')
    .replace(/\s+/g, ' ')
    .trim();
  if (!name || name.length < 3 || name.length > 80) return '';
  if (/^(why|pros|cons|price|location|recommendation|option|summary|final|notes?|action|status|detail|field)$/i.test(name)) return '';
  if (!/[A-Z]/.test(name)) return '';
  return name;
}

function addRecentCorrection(frame, text) {
  const sentence = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 240);
  if (!sentence) return;
  addUnique(frame.recentCorrections, sentence);
  if (frame.recentCorrections.length > 5) frame.recentCorrections = frame.recentCorrections.slice(-5);
}

function scoreFrameConfidence(frame) {
  let score = 0.35;
  const semanticScopeKeys = Object.keys(frame.scope || {}).filter((key) => key !== 'projectPath');
  if (frame.domain && frame.domain !== 'general') score += 0.15;
  if (frame.activeGoal && frame.activeGoal !== 'answer current user request') score += 0.1;
  if (semanticScopeKeys.length > 0) score += 0.15;
  if ((frame.constraints || []).length > 0) score += 0.1;
  if (frame.domain === 'coding' && frame.scope?.projectPath) score += 0.1;
  if (frame.scope?.lodgingLocation || frame.scope?.destination) score += 0.1;
  return Math.max(0.1, Math.min(0.95, Number(score.toFixed(2))));
}

function pruneFrame(frame) {
  const out = JSON.parse(JSON.stringify(frame || {}));
  out.constraints = (out.constraints || []).filter(Boolean).slice(-16);
  out.recentCorrections = (out.recentCorrections || []).filter(Boolean).slice(-5);
  out.inactiveRelatedFrames = (out.inactiveRelatedFrames || []).filter(Boolean).slice(-8);
  out.previousRecommendations = (out.previousRecommendations || []).filter(Boolean).slice(0, MAX_RECOMMENDATION_NAMES);
  out.evidenceRefs = (out.evidenceRefs || []).slice(-8);
  return out;
}

function validatorsForFrame(frame) {
  const validators = [];
  if (frame?.domain === 'travel' || frame?.activeGoal?.includes('recommend') || frame?.scope?.lodgingLocation) {
    validators.push('travel_recommendation_scope');
  }
  if (frame?.domain === 'email') validators.push('email_action_scope');
  if (frame?.domain === 'calendar') validators.push('calendar_action_scope');
  if (frame?.domain === 'coding') validators.push('coding_completion_scope');
  return validators;
}

function buildConversationFramePrompt(frame) {
  if (!frame || frame.confidence < 0.45) return '';
  const lines = [];
  lines.push('## Active Conversation Frame');
  lines.push('This frame is the source of truth for the current task. The latest user correction wins over older assistant suggestions, memories, artifacts, and neighboring CTM tabs.');
  lines.push(`Domain: ${frame.domain || 'general'}`);
  lines.push(`Active goal: ${frame.activeGoal || 'answer current user request'}`);
  const scopeLines = Object.entries(frame.scope || {})
    .filter(([, value]) => value != null && value !== '' && (!Array.isArray(value) || value.length))
    .map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`);
  if (scopeLines.length) lines.push(`Scope:\n${scopeLines.join('\n')}`);
  if (frame.constraints?.length) lines.push(`Constraints:\n${frame.constraints.map(c => `- ${c}`).join('\n')}`);
  if (frame.recentCorrections?.length) lines.push(`Recent user corrections:\n${frame.recentCorrections.map(c => `- ${c}`).join('\n')}`);
  if (frame.inactiveRelatedFrames?.length) lines.push(`Related but inactive context:\n${frame.inactiveRelatedFrames.map(c => `- ${c}`).join('\n')}`);
  if (frame.previousRecommendations?.length) lines.push(`Previously recommended candidates to avoid repeating unless justified:\n${frame.previousRecommendations.map(c => `- ${c}`).join('\n')}`);
  lines.push('Before finalizing, verify the answer matches this frame. If it conflicts, repair the answer rather than claiming success.');
  return `\n\n${lines.join('\n')}`;
}

function validateFrameAnswer(text, frame) {
  const violations = [];
  const value = String(text || '');
  if (!value.trim() || !frame) return { ok: true, violations };

  if (validatorsForFrame(frame).includes('travel_recommendation_scope')) {
    validateTravelRecommendationAnswer(value, frame, violations);
  }
  if (validatorsForFrame(frame).includes('email_action_scope')) {
    validateEmailActionAnswer(value, frame, violations);
  }
  if (validatorsForFrame(frame).includes('calendar_action_scope')) {
    validateCalendarActionAnswer(value, frame, violations);
  }
  if (validatorsForFrame(frame).includes('coding_completion_scope')) {
    validateCodingAnswer(value, frame, violations);
  }

  return { ok: violations.length === 0, violations };
}

function validateTravelRecommendationAnswer(text, frame, violations) {
  const activeLocation = frame.scope?.lodgingLocation || frame.scope?.destination || '';
  const recommendationLike = RECOMMENDATION_RE.test(text) || LODGING_RE.test(text);
  if (activeLocation && recommendationLike) {
    const hasActive = containsComparable(text, activeLocation);
    const inactiveHit = (frame.inactiveRelatedFrames || []).find((loc) => loc && containsComparable(text, loc));
    if (inactiveHit && !hasActive) {
      violations.push({
        code: 'scope_drift',
        message: `Answer appears to use inactive location "${inactiveHit}" instead of active location "${activeLocation}".`,
      });
    }
  }
  if ((frame.constraints || []).includes('include_supply_type:alternative_rentals')) {
    if (!/\b(airbnb|vrbo|vacation rental|short[- ]term rental|home rental|condo|rental home|rental house|apartment rental)\b/i.test(text)) {
      violations.push({
        code: 'missing_allowed_supply_type',
        message: 'User explicitly allowed Airbnb/alternative rentals, but the answer does not include that supply type.',
      });
    }
  }
  if ((frame.constraints || []).includes('requires_novel_recommendations')) {
    const previous = (frame.previousRecommendations || []).filter(Boolean);
    if (previous.length > 0) {
      const repeated = previous.filter((name) => containsComparable(text, name));
      const currentNames = extractRecommendationNames(text);
      const newNames = currentNames.filter((name) => !previous.some((prev) => normalizeComparable(prev) === normalizeComparable(name)));
      if (repeated.length >= Math.min(2, previous.length) && newNames.length < 2) {
        violations.push({
          code: 'not_novel_recommendations',
          message: `User asked for changed recommendations, but the answer repeats prior candidates: ${repeated.slice(0, 4).join(', ')}.`,
        });
      }
    }
  }
  if ((frame.constraints || []).includes('not_brand_limited:hilton')) {
    const mentionsHilton = /\bhilton\b/i.test(text);
    const hasNonHiltonAlternative = /\b(airbnb|vrbo|marriott|hyatt|ihg|independent|boutique|vacation rental|condo|rental)\b/i.test(text);
    if (mentionsHilton && !hasNonHiltonAlternative) {
      violations.push({
        code: 'brand_limited',
        message: 'User said the answer does not have to be Hilton-only, but the response remains brand-limited.',
      });
    }
  }
}

function validateEmailActionAnswer(text, frame, violations) {
  if (!(frame.constraints || []).includes('email_action:reply_to_original_thread')) return;
  if (/\b(new email|separate email|fresh email|new thread)\b/i.test(text) && !/\boriginal thread|same thread|reply\b/i.test(text)) {
    violations.push({
      code: 'email_thread_drift',
      message: 'User asked to reply/respond to the original email thread, but the answer describes a new email/thread.',
    });
  }
}

function validateCalendarActionAnswer(text, frame, violations) {
  const calendars = frame.scope?.calendars || [];
  if (calendars.length < 2) return;
  for (const cal of calendars) {
    if (!new RegExp(`\\b${escapeRegExp(cal)}\\b`, 'i').test(text)) {
      violations.push({
        code: 'calendar_target_missing',
        message: `User requested calendar target "${cal}", but the final answer does not mention it.`,
      });
    }
  }
}

function validateCodingAnswer(text, frame, violations) {
  if (!(frame.constraints || []).includes('verification_required')) return;
  const claimsCompletion = /\b(done|complete|completed|fixed|implemented)\b/i.test(text);
  const mentionsVerificationEvidence = /\b(test(?:ed|s)?|verified|validated|checked|ran|screenshot|browser_screenshot|check_url|build|lint|typecheck|evidence)\b/i.test(text);
  if (claimsCompletion && !mentionsVerificationEvidence) {
    violations.push({
      code: 'missing_verification_evidence',
      message: 'User asked for verification, but the final completion claim does not mention validation evidence.',
    });
  }
}

function buildFrameRepairPrompt({ frame, violations, originalUserMessage } = {}) {
  const issueLines = (violations || []).map((v) => `- ${v.code}: ${v.message}`).join('\n') || '- frame_mismatch';
  const frameJson = JSON.stringify(frame || {}, null, 2);
  return [
    'Your previous final answer failed Wall-E active-frame validation.',
    'Rewrite the final answer only. Do not mention this validator or internal frame.',
    'The latest user message and Active Conversation Frame are authoritative over older assistant replies, memories, artifacts, and neighboring sessions.',
    '',
    `Latest user message:\n${String(originalUserMessage || '').slice(0, 1200)}`,
    '',
    `Active Conversation Frame:\n${frameJson.slice(0, 4000)}`,
    '',
    `Violations:\n${issueLines}`,
    '',
    'Return a corrected final answer that satisfies every violation. Keep the same response language as the latest user message.',
  ].join('\n');
}

function containsComparable(text, needle) {
  const source = normalizeComparable(text);
  const target = normalizeComparable(needle);
  if (!source || !target) return false;
  if (source.includes(target)) return true;
  if (/^[a-z]{3}$/.test(target)) return new RegExp(`\\b${escapeRegExp(target)}\\b`, 'i').test(String(text || ''));
  return false;
}

function normalizeComparable(value) {
  return String(value || '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}

function addUnique(list, value) {
  const clean = typeof value === 'string' ? value.trim() : value;
  if (!clean) return;
  const key = normalizeComparable(clean);
  if (!key) return;
  if (!list.some((item) => normalizeComparable(item) === key)) list.push(clean);
}

function uniqueByLower(value, index, array) {
  const key = normalizeComparable(value);
  return key && array.findIndex((item) => normalizeComparable(item) === key) === index;
}

function escapeRegExp(value) {
  return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

module.exports = {
  FRAME_SCHEMA_VERSION,
  buildConversationRuntime,
  buildConversationFramePrompt,
  buildFrameRepairPrompt,
  deriveConversationFrame,
  extractRecommendationNames,
  normalizeSemanticMessages,
  parseSessionMetadata,
  validateFrameAnswer,
};
