/**
 * Service for scoring job suitability using AI.
 */

import { logger } from "@infra/logger";
import { textMatchesKeyword } from "@shared/keyword-match";
import type { Job, JobSearchProfile, SuitabilityAnalysis } from "@shared/types";
import { jobLikelyRequiresOfficePresence } from "@shared/work-arrangement";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import { stripMarkdownCodeFences } from "./llm/utils/json";
import { resolveLlmModel } from "./modelSelection";
import { getEffectiveSettings } from "./settings";

interface SuitabilityResult {
  score: number;
  reason: string;
  analysis: SuitabilityAnalysis | null;
}

type ScoringPreferences = {
  instructions: string;
  jobSearchProfile: JobSearchProfile | null;
};

/** JSON schema for suitability scoring response (enhanced with analysis) */
const SCORING_SCHEMA: JsonSchemaDefinition = {
  name: "job_suitability_score",
  schema: {
    type: "object",
    properties: {
      score: {
        type: "integer",
        description: "Suitability score from 0 to 100",
      },
      reason: {
        type: "string",
        description: "Brief 1-2 sentence explanation of the score",
      },
      roleTypeMatch: {
        type: "integer",
        description:
          "How well the job role type matches what the candidate wants (0-100)",
      },
      workArrangementMatch: {
        type: "integer",
        description:
          "How well the job's work location arrangement matches the candidate's preferences: remote vs hybrid vs on-site (0-100). 100 = perfect match (e.g. fully remote job when candidate wants remote only).",
      },
      strengths: {
        type: "array",
        items: { type: "string" },
        description:
          "2-4 specific strengths where the candidate matches this job well",
      },
      gaps: {
        type: "array",
        items: { type: "string" },
        description:
          "1-3 specific skills or requirements the candidate is missing for this job",
      },
      suggestions: {
        type: "array",
        items: { type: "string" },
        description:
          "1-3 actionable suggestions to improve candidacy for this type of role",
      },
      dealBreakerHits: {
        type: "array",
        items: { type: "string" },
        description:
          "Deal-breakers from the candidate profile or hard disqualifiers in the job (e.g. U.S.-only citizenship, clearance) that this job triggers. If this array is non-empty, score MUST be 0-15.",
      },
    },
    required: [
      "score",
      "reason",
      "roleTypeMatch",
      "workArrangementMatch",
      "strengths",
      "gaps",
      "suggestions",
      "dealBreakerHits",
    ],
    additionalProperties: false,
  },
};

interface ScoringLlmResponse {
  score: number;
  reason: string;
  roleTypeMatch?: number;
  workArrangementMatch?: number;
  strengths?: string[];
  gaps?: string[];
  suggestions?: string[];
  dealBreakerHits?: string[];
}

/**
 * Check if a job's salary field is missing/empty.
 * Returns true for null, empty string, or whitespace-only strings.
 */
function isSalaryMissing(salary: string | null): boolean {
  return salary === null || salary.trim() === "";
}

/**
 * Apply salary penalty to a score if enabled.
 * Returns the adjusted score, adjusted reason, and whether penalty was applied.
 */
function applySalaryPenalty(
  job: Job,
  originalScore: number,
  originalReason: string,
  settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
): { score: number; reason: string; penaltyApplied: boolean } {
  if (!settings.penalizeMissingSalary || !isSalaryMissing(job.salary)) {
    return {
      score: originalScore,
      reason: originalReason,
      penaltyApplied: false,
    };
  }

  const penalty = settings.missingSalaryPenalty;
  const adjustedScore = Math.max(0, originalScore - penalty);
  const penaltyText = `Score reduced by ${penalty} points due to missing salary information.`;
  const adjustedReason = `${originalReason} ${penaltyText}`;

  logger.info("Applied salary penalty", {
    jobId: job.id,
    originalScore,
    penalty,
    finalScore: adjustedScore,
  });

  return { score: adjustedScore, reason: adjustedReason, penaltyApplied: true };
}

function extractAnalysis(data: ScoringLlmResponse): SuitabilityAnalysis | null {
  const strengths = Array.isArray(data.strengths) ? data.strengths : [];
  const gaps = Array.isArray(data.gaps) ? data.gaps : [];
  const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
  const dealBreakerHits = Array.isArray(data.dealBreakerHits)
    ? data.dealBreakerHits
    : [];
  const hasRoleType = typeof data.roleTypeMatch === "number";
  const hasWorkArrangement = typeof data.workArrangementMatch === "number";
  const hasAny =
    strengths.length > 0 ||
    gaps.length > 0 ||
    suggestions.length > 0 ||
    dealBreakerHits.length > 0 ||
    hasRoleType ||
    hasWorkArrangement;

  if (!hasAny) return null;

  return {
    roleTypeMatch: hasRoleType
      ? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch as number)))
      : 50,
    workArrangementMatch: hasWorkArrangement
      ? Math.min(
          100,
          Math.max(0, Math.round(data.workArrangementMatch as number)),
        )
      : undefined,
    strengths,
    gaps,
    suggestions,
    dealBreakerHits,
  };
}

/**
 * Score a job's suitability based on profile and job description.
 * Includes retry logic for when AI returns garbage responses.
 */
function scoreForProfileDealBreakers(
  job: Job,
  jobSearchProfile: JobSearchProfile,
): SuitabilityResult | null {
  if (jobSearchProfile.dealBreakers.length === 0) return null;
  const haystack = `${job.title}\n${job.jobDescription ?? ""}`;
  const hit = jobSearchProfile.dealBreakers.find((breaker) =>
    textMatchesKeyword(haystack, breaker),
  );
  if (!hit) return null;
  return {
    score: 10,
    reason: `Deal-breaker "${hit}" matched in the job title or description.`,
    analysis: {
      roleTypeMatch: 15,
      workArrangementMatch: undefined,
      strengths: [],
      gaps: [`Posting matches profile deal-breaker: ${hit}`],
      suggestions: ["Skip co-op, intern, and other excluded role types"],
      dealBreakerHits: [`Profile deal-breaker: ${hit}`],
    },
  };
}

export async function scoreJobSuitability(
  job: Job,
  profile: Record<string, unknown>,
): Promise<SuitabilityResult> {
  const [model, settings] = await Promise.all([
    resolveLlmModel("scoring"),
    getEffectiveSettings(),
  ]);

  const jobSearchProfile = settings.jobSearchProfile?.value ?? null;
  const hasProfile = jobSearchProfile && hasNonEmptyProfile(jobSearchProfile);

  if (hasProfile && jobSearchProfile) {
    const dealBreakerScore = scoreForProfileDealBreakers(job, jobSearchProfile);
    if (dealBreakerScore) return dealBreakerScore;
  }

  const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
    instructions: settings.scoringInstructions?.value ?? "",
    jobSearchProfile: hasProfile ? jobSearchProfile : null,
  });

  const llm = new LlmService();
  const result = await llm.callJson<ScoringLlmResponse>({
    model,
    messages: [{ role: "user", content: prompt }],
    jsonSchema: SCORING_SCHEMA,
    maxRetries: 2,
    jobId: job.id,
  });

  if (!result.success) {
    if (result.error.toLowerCase().includes("api key")) {
      logger.warn("LLM API key not set, using mock scoring", { jobId: job.id });
    }
    logger.error("Scoring failed, using mock scoring", {
      jobId: job.id,
      error: result.error,
    });
    return mockScore(job, {
      penalizeMissingSalary: settings.penalizeMissingSalary.value,
      missingSalaryPenalty: settings.missingSalaryPenalty.value,
    });
  }

  const data = result.data;

  if (typeof data.score !== "number" || Number.isNaN(data.score)) {
    logger.error("Invalid score in AI response, using mock scoring", {
      jobId: job.id,
    });
    return mockScore(job, {
      penalizeMissingSalary: settings.penalizeMissingSalary.value,
      missingSalaryPenalty: settings.missingSalaryPenalty.value,
    });
  }

  let clampedScore = Math.min(100, Math.max(0, Math.round(data.score)));
  let clampedReason = data.reason || "No explanation provided";
  const analysis = extractAnalysis(data);

  const roleTypeMatchCap = applyRoleTypeMatchCap(
    clampedScore,
    clampedReason,
    data,
    hasProfile ? jobSearchProfile : null,
  );
  clampedScore = roleTypeMatchCap.score;
  clampedReason = roleTypeMatchCap.reason;

  const remoteCap = applyRemoteOfficeMismatchCap(
    job,
    clampedScore,
    clampedReason,
    data,
    hasProfile ? jobSearchProfile : null,
  );
  clampedScore = remoteCap.score;
  clampedReason = remoteCap.reason;

  const dealBreakerCap = applyDealBreakerHitsCap(
    job.id,
    clampedScore,
    clampedReason,
    analysis,
  );
  clampedScore = dealBreakerCap.score;
  clampedReason = dealBreakerCap.reason;

  const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
    penalizeMissingSalary: settings.penalizeMissingSalary.value,
    missingSalaryPenalty: settings.missingSalaryPenalty.value,
  });

  return {
    score: penaltyResult.score,
    reason: penaltyResult.reason,
    analysis,
  };
}

const ROLE_TYPE_MATCH_CAP_THRESHOLD = 40;
const ROLE_TYPE_MATCH_SCORE_CEILING = 25;

/**
 * When the LLM reports a low roleTypeMatch but still assigns a high overall
 * score (common with smaller models that overweight keyword overlap), enforce
 * a hard ceiling so role-type mismatches can't float to the top of the list.
 */
function applyRoleTypeMatchCap(
  score: number,
  reason: string,
  data: ScoringLlmResponse,
  jobSearchProfile: JobSearchProfile | null,
): { score: number; reason: string } {
  if (!jobSearchProfile || jobSearchProfile.targetRoles.length === 0) {
    return { score, reason };
  }

  const roleTypeMatch =
    typeof data.roleTypeMatch === "number" ? data.roleTypeMatch : null;
  if (
    roleTypeMatch === null ||
    roleTypeMatch >= ROLE_TYPE_MATCH_CAP_THRESHOLD
  ) {
    return { score, reason };
  }

  if (score <= ROLE_TYPE_MATCH_SCORE_CEILING) {
    return { score, reason };
  }

  const cappedScore = Math.min(score, ROLE_TYPE_MATCH_SCORE_CEILING);
  const capNote = `Capped from ${score} to ${cappedScore} (role type match ${roleTypeMatch}% < ${ROLE_TYPE_MATCH_CAP_THRESHOLD}% threshold).`;
  logger.info("Applied role-type match cap", {
    originalScore: score,
    cappedScore,
    roleTypeMatch,
  });
  return { score: cappedScore, reason: `${reason} ${capNote}` };
}

const REMOTE_ONLY_OFFICE_MISMATCH_CEILING = 35;
const WORK_ARRANGEMENT_MATCH_CAP_THRESHOLD = 40;

/**
 * Candidate selected "Remote" in job search profile but not "Hybrid" or "On-site".
 */
function candidateWantsRemoteOnly(p: JobSearchProfile): boolean {
  const wa = p.preferredWorkArrangement.map((s) => s.trim().toLowerCase());
  if (!wa.includes("remote")) return false;
  if (wa.includes("hybrid") || wa.includes("onsite")) return false;
  return true;
}

/**
 * Cap score when candidate wants remote-only but the job is hybrid / on-site, or
 * when the model admits a poor work-arrangement fit but still scores high.
 */
function applyRemoteOfficeMismatchCap(
  job: Job,
  score: number,
  reason: string,
  data: ScoringLlmResponse,
  jobSearchProfile: JobSearchProfile | null,
): { score: number; reason: string } {
  if (!jobSearchProfile || !candidateWantsRemoteOnly(jobSearchProfile)) {
    return { score, reason };
  }

  const officeLikely = jobLikelyRequiresOfficePresence(job);
  const wam =
    typeof data.workArrangementMatch === "number"
      ? data.workArrangementMatch
      : null;
  const llmSaysPoorArrangement =
    wam !== null && wam < WORK_ARRANGEMENT_MATCH_CAP_THRESHOLD;

  if (!officeLikely && !llmSaysPoorArrangement) {
    return { score, reason };
  }

  if (score <= REMOTE_ONLY_OFFICE_MISMATCH_CEILING) {
    return { score, reason };
  }

  const cappedScore = Math.min(score, REMOTE_ONLY_OFFICE_MISMATCH_CEILING);
  const why = officeLikely
    ? "job appears hybrid or on-site"
    : `work arrangement match ${wam}% < ${WORK_ARRANGEMENT_MATCH_CAP_THRESHOLD}%`;
  const capNote = `Capped from ${score} to ${cappedScore} (remote-only preference vs ${why}).`;
  logger.info("Applied remote-only / office mismatch cap", {
    jobId: job.id,
    originalScore: score,
    cappedScore,
    officeLikely,
    workArrangementMatch: wam,
  });
  return { score: cappedScore, reason: `${reason} ${capNote}` };
}

/** When the model lists deal-breakers but still returns a high score, enforce the same ceiling as the prompt. */
const DEAL_BREAKER_HIT_SCORE_CEILING = 15;

function applyDealBreakerHitsCap(
  jobId: string,
  score: number,
  reason: string,
  analysis: SuitabilityAnalysis | null,
): { score: number; reason: string } {
  const hits = analysis?.dealBreakerHits ?? [];
  if (hits.length === 0 || score <= DEAL_BREAKER_HIT_SCORE_CEILING) {
    return { score, reason };
  }

  const cappedScore = Math.min(score, DEAL_BREAKER_HIT_SCORE_CEILING);
  const capNote = `Capped from ${score} to ${cappedScore} (deal-breakers: ${hits.join("; ")}).`;
  logger.info("Applied deal-breaker score cap", {
    jobId,
    originalScore: score,
    cappedScore,
    dealBreakerHits: hits,
  });
  return { score: cappedScore, reason: `${reason} ${capNote}` };
}

function hasNonEmptyProfile(p: JobSearchProfile): boolean {
  return (
    p.targetRoles.length > 0 ||
    p.mustHaveSkills.length > 0 ||
    p.dealBreakers.length > 0 ||
    p.preferredWorkArrangement.length > 0 ||
    p.preferredLocations.length > 0 ||
    p.aboutMe.trim().length > 0 ||
    p.experienceLevel.trim().length > 0
  );
}

/**
 * Robustly parse JSON from AI-generated content.
 * Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
 *
 * @deprecated Use LlmService with structured outputs instead. Kept for backwards compatibility with tests.
 */
export function parseJsonFromContent(
  content: string,
  jobId?: string,
): { score?: number; reason?: string } {
  const originalContent = content;
  let candidate = content.trim();

  candidate = stripMarkdownCodeFences(candidate);

  const jsonMatch = candidate.match(/\{[\s\S]*\}/);
  if (jsonMatch) {
    candidate = jsonMatch[0];
  }

  try {
    return JSON.parse(candidate);
  } catch {
    // Continue with sanitization
  }

  let sanitized = candidate;

  sanitized = sanitized.replace(/\/\/[^\n]*/g, "");
  sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, "");

  sanitized = sanitized.replace(/,\s*([\]}])/g, "$1");

  sanitized = sanitized.replace(
    /([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g,
    '$1"$2":',
  );

  sanitized = sanitized.replace(/'/g, '"');

  // biome-ignore lint/suspicious/noControlCharactersInRegex: needed to fix broken JSON from AI
  const controlCharsRegex = /[\x00-\x1F\x7F]/g;
  sanitized = sanitized.replace(controlCharsRegex, (match) => {
    if (match === "\n") return "\\n";
    if (match === "\r") return "\\r";
    if (match === "\t") return "\\t";
    return "";
  });

  try {
    return JSON.parse(sanitized);
  } catch {
    // Continue with more aggressive extraction
  }

  const scoreMatch = originalContent.match(
    /["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i,
  );
  const reasonMatch =
    originalContent.match(/["']?reason["']?\s*[:=]\s*["']([^"'\n]+)["']/i) ||
    originalContent.match(
      /["']?reason["']?\s*[:=]\s*["']?(.*?)["']?\s*[,}\n]/is,
    );

  if (scoreMatch) {
    const score = Math.round(parseFloat(scoreMatch[1]));
    const reason = reasonMatch
      ? reasonMatch[1].trim().replace(controlCharsRegex, "")
      : "Score extracted from malformed response";
    logger.warn("Parsed score via regex fallback", {
      jobId: jobId || "unknown",
      score,
    });
    return { score, reason };
  }

  logger.error("Failed to parse AI response", {
    jobId: jobId || "unknown",
    rawSample: originalContent.substring(0, 500),
    sanitizedSample: sanitized.substring(0, 500),
  });

  throw new Error("Unable to parse JSON from model response");
}

function buildScoringPrompt(
  job: Job,
  profile: Record<string, unknown>,
  preferences: ScoringPreferences,
): string {
  const p = preferences.jobSearchProfile;
  const hasProfilePrefs = p !== null;

  const profilePrefsBlock = hasProfilePrefs
    ? `
CANDIDATE JOB SEARCH PREFERENCES (CRITICAL - weigh these heavily):
Target Roles: ${p.targetRoles.length > 0 ? p.targetRoles.join(", ") : "Not specified"}
Experience Level: ${p.experienceLevel || "Not specified"}
Must-Have Skills: ${p.mustHaveSkills.length > 0 ? p.mustHaveSkills.join(", ") : "Not specified"}
Nice-to-Have Skills: ${p.niceToHaveSkills.length > 0 ? p.niceToHaveSkills.join(", ") : "Not specified"}
Deal-Breakers (score 0-15 if triggered): ${p.dealBreakers.length > 0 ? p.dealBreakers.join(", ") : "None"}
Preferred Work Arrangement: ${p.preferredWorkArrangement.length > 0 ? p.preferredWorkArrangement.join(", ") : "Any"}
Preferred Locations: ${p.preferredLocations.length > 0 ? p.preferredLocations.join(", ") : "Any"}
Minimum Salary: ${p.minimumSalary || "Not specified"}
Target Industries: ${p.industriesToTarget.length > 0 ? p.industriesToTarget.join(", ") : "Any"}
Industries to Avoid: ${p.industriesToAvoid.length > 0 ? p.industriesToAvoid.join(", ") : "None"}
About the Candidate: ${p.aboutMe || "Not provided"}`
    : "";

  const dealBreakerRules = hasProfilePrefs
    ? `
DEAL-BREAKER RULES (STRICTLY ENFORCE — these override all other criteria):
- ROLE TYPE IS THE PRIMARY GATE. Evaluate the JOB TITLE and PRIMARY RESPONSIBILITIES first.
  If the core role type mismatches the candidate's target roles, score MUST be 0-20 and roleTypeMatch MUST be 0-30.
  Do NOT inflate the score because the candidate's resume languages (e.g. C#, Java, Python) overlap with the job's language requirements.
  Shared programming languages alone do NOT make a role a good fit.
- Examples of role-type mismatches (all should score 0-20):
  * Candidate targets "QA Automation / SDET" → "Senior Software Engineer" (core dev, not QA) = score 10-20
  * Candidate targets "QA Automation / SDET" → "Backend Engineer" (core dev) = score 10-20
  * Candidate targets "QA Automation / SDET" → "DevOps Engineer" (infra, not QA) = score 10-15
  * Candidate targets "QA Automation / SDET" → "Data Scientist" = score 0-10
- Examples of good role-type matches (eligible for full score range):
  * Candidate targets "QA Automation / SDET" → "SDET" = roleTypeMatch 95-100
  * Candidate targets "QA Automation / SDET" → "QA Automation Engineer" = roleTypeMatch 90-100
  * Candidate targets "QA Automation / SDET" → "Software Engineer in Test" = roleTypeMatch 85-95
  * Candidate targets "QA Automation / SDET" → "Test Automation Engineer" = roleTypeMatch 85-95
- If any deal-breaker keywords appear in the job title or core requirements, score MUST be 0-15.
- Clearance / citizenship (hard gates — not the same as "authorized to work in the U.S."):
  * If the role requires U.S. citizenship only (e.g. "U.S. Citizenship required", "must be a U.S. citizen", "not available to visa holders"),
    or active government clearance the job states as required (Secret, Top Secret, TS/SCI, "full scope poly", SCI, SAP),
    and that conflicts with the candidate's profile, visa situation, or job-search deal-breakers, score MUST be 0-15
    and explain in dealBreakerHits. Do NOT assign a high score for strong skill overlap when the candidate cannot be hired.
- If dealBreakerHits is non-empty, score MUST be 0-15 (never above 15).
- If the job requires experience far beyond the candidate's level, reduce score by 30-50 points.
- A job mentioning a candidate's skill as a minor "nice-to-have" does NOT make it a good match
  if the core role is completely different from what the candidate wants.
- When in doubt about role type, err on the side of a LOWER score. The candidate would rather miss
  a borderline match than waste time on roles that don't align with their career focus.
- WORK ARRANGEMENT (read the full job description — do not trust a vague "Remote" tag alone):
  * If Preferred Work Arrangement is ONLY "Remote" (no Hybrid / On-site selected): any hybrid role,
    fixed days in office, or mandatory on-site requirement MUST score 0-35 overall and workArrangementMatch 0-25.
    Examples: "3 days a week in office", "hybrid", "on-site 2 days", "in-person collaboration required".
  * If the candidate also selected Hybrid or On-site, do not treat hybrid as a hard mismatch.
  * Fully remote / work-from-anywhere jobs when candidate wants remote: workArrangementMatch 90-100.
  * Mention hybrid/on-site mismatch in dealBreakerHits when the candidate is remote-only.`
    : "";

  const scoringCriteria = hasProfilePrefs
    ? `SCORING CRITERIA (with candidate preferences):
- Role type alignment with target roles: 0-40 points (GATING FACTOR — if this is below 15, the total score MUST be below 25 regardless of other criteria)
- Skills match with role-relevant skills (must-haves weighted 3x, nice-to-haves 1x): 0-25 points
- Experience level match: 0-15 points
- Location / remote vs hybrid / on-site alignment with preferences: 0-15 points (GATING when candidate is remote-only and job requires office days)
- Industry/domain fit: 0-5 points
- Career growth and salary alignment: 0-5 points

CRITICAL: A "Senior Software Engineer" role and a "QA Automation Engineer" role are FUNDAMENTALLY DIFFERENT job types even if they share programming languages. Evaluate the PRIMARY function of the role, not just keyword overlap.
CRITICAL: If the candidate wants REMOTE ONLY and the posting requires any regular office presence, the TOTAL score cannot exceed ~35 regardless of skills match.`
    : `SCORING CRITERIA:
- Skills match (technologies, frameworks, languages): 0-30 points
- Experience level match: 0-25 points
- Location/remote work alignment: 0-15 points
- Industry/domain fit: 0-15 points
- Career growth potential: 0-15 points`;

  return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.

${scoringCriteria}
${dealBreakerRules}
${profilePrefsBlock}

CANDIDATE RESUME:
${JSON.stringify(profile, null, 2)}

JOB LISTING:
Title: ${job.title}
Employer: ${job.employer}
Location: ${job.location || "Not specified"}
Salary: ${job.salary || "Not specified"}
Degree Required: ${job.degreeRequired || "Not specified"}
Disciplines: ${job.disciplines || "Not specified"}

JOB DESCRIPTION:
${job.jobDescription || "No description available"}

SCORING INSTRUCTIONS:
${
  preferences.instructions
    ? preferences.instructions
    : "No additional custom scoring instructions."
}

IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.

REQUIRED FORMAT (exactly this structure):
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>", "roleTypeMatch": <integer 0-100>, "workArrangementMatch": <integer 0-100>, "strengths": ["<strength 1>", "<strength 2>"], "gaps": ["<gap 1>"], "suggestions": ["<suggestion 1>"], "dealBreakerHits": []}

RULES FOR ANALYSIS FIELDS:
- "roleTypeMatch": How well does this job's role TYPE match what the candidate wants? 100 = perfect role type, 0 = completely wrong type of work.
- "workArrangementMatch": How well do location and work arrangement match? E.g. remote-only candidate + fully remote job = 95-100; remote-only + hybrid or N days in office = 0-25.
- "strengths": 2-4 specific things where the candidate is a strong match. Be concrete (e.g. "Has 2 years React experience matching the 1+ year requirement").
- "gaps": 1-3 specific skills/requirements the candidate lacks. Be honest and specific.
- "suggestions": 1-3 actionable things the candidate could do to be stronger for this type of role.
- "dealBreakerHits": List any deal-breakers triggered. Empty array if none. If non-empty, score MUST be 0-15.

EXAMPLE RESPONSES:

Role mismatch (candidate wants QA/SDET, job is core software engineering):
{"score": 15, "reason": "This is a core software engineering role focused on GPU infrastructure and platform development. Despite shared languages (C#, Java, Python), the primary responsibilities are software development, not testing or quality assurance.", "roleTypeMatch": 10, "workArrangementMatch": 70, "strengths": ["Experience with C# and Java", "Familiar with Azure cloud"], "gaps": ["Role is software development, not QA/SDET", "No GPU/HPC infrastructure experience", "No hardware/software interaction experience"], "suggestions": ["Focus on SDET or QA Automation roles at Microsoft instead", "Look for test infrastructure roles in cloud platform teams"], "dealBreakerHits": ["Role type mismatch: Senior Software Engineer (development) vs target of QA Automation/SDET"]}

Remote-only candidate, hybrid job (skills match but office days required):
{"score": 28, "reason": "Strong technical fit but the role is hybrid with multiple days per week in the office; candidate preferences indicate remote-only.", "roleTypeMatch": 85, "workArrangementMatch": 15, "strengths": ["Stack aligns with automation and CI experience"], "gaps": ["Hybrid / in-office requirement conflicts with remote-only preference"], "suggestions": ["Filter for fully remote listings or select Hybrid in job search preferences if open to it"], "dealBreakerHits": ["Work arrangement: hybrid / in-office vs remote-only preference"]}

Good match (role aligns with candidate's target):
{"score": 78, "reason": "Strong QA automation role with Playwright requirement matching the candidate's core expertise. CI/CD pipeline ownership aligns well with their DevOps experience.", "roleTypeMatch": 90, "workArrangementMatch": 95, "strengths": ["5+ years Playwright experience exceeds the 2-year requirement", "Strong CI/CD pipeline experience with GitHub Actions"], "gaps": ["No experience with the company's specific domain"], "suggestions": ["Highlight regulated-industry QA experience from iGaming role"], "dealBreakerHits": []}

U.S. citizenship or TS/SCI required — technical skills match but candidate cannot meet eligibility:
{"score": 12, "reason": "Despite alignment on automation and testing stack, the posting requires U.S. citizenship (or TS/SCI eligibility) that disqualifies a non-citizen or uncleared candidate regardless of skills.", "roleTypeMatch": 88, "workArrangementMatch": 80, "strengths": ["Strong test automation and CI/CD background"], "gaps": ["Mandatory U.S. citizenship or active TS/SCI not satisfied"], "suggestions": ["Focus on commercial-sector SDET roles without citizenship-only requirements"], "dealBreakerHits": ["Eligibility: U.S. citizenship / TS/SCI required"]}`;
}

export function sanitizeProfileForPrompt(
  profile: Record<string, unknown>,
  options?: { maxExperienceItems?: number; maxProjectItems?: number },
): Record<string, unknown> {
  const maxExperienceItems = options?.maxExperienceItems ?? 5;
  const maxProjectItems = options?.maxProjectItems ?? 6;

  const p = profile as {
    basics?: Record<string, unknown>;
    sections?: {
      skills?: unknown;
      experience?: { items?: unknown[] };
      projects?: { items?: unknown[] };
      education?: { items?: unknown[] };
    };
  };

  const experienceItems = Array.isArray(p.sections?.experience?.items)
    ? p.sections?.experience?.items.slice(0, maxExperienceItems)
    : [];
  const projectItems = Array.isArray(p.sections?.projects?.items)
    ? p.sections?.projects?.items.slice(0, maxProjectItems)
    : [];

  const basics = p.basics as Record<string, unknown> | undefined;
  const loc = basics?.location as Record<string, unknown> | undefined;
  return {
    basics: {
      name: basics?.name ?? null,
      headline: basics?.headline || basics?.label || null,
      summary: basics?.summary,
      location:
        loc && typeof loc === "object"
          ? {
              city: loc.city ?? null,
              region: loc.region ?? null,
              countryCode: loc.countryCode ?? null,
            }
          : null,
    },
    skills: p.sections?.skills ?? null,
    experience: experienceItems,
    projects: projectItems,
    education: p.sections?.education?.items ?? [],
  };
}

const MOCK_TITLE_BOOST_KEYWORDS = [
  "qa",
  "qe",
  "sdet",
  "test",
  "quality",
  "automation",
  "playwright",
  "cypress",
  "selenium",
];

const MOCK_JD_BOOST_KEYWORDS = [
  "playwright",
  "cypress",
  "selenium",
  "test automation",
  "qa automation",
  "quality assurance",
  "sdet",
  "shift-left",
  "bdd",
  "e2e",
  "end-to-end",
  "regression",
  "ci/cd",
  "typescript",
  "javascript",
];

const MOCK_PENALTY_KEYWORDS = [
  "principal",
  "staff",
  "manager",
  "director",
  "vp ",
  "vice president",
  "instructor",
  "trainer",
  "teacher",
];

async function mockScore(
  job: Job,
  settings: { penalizeMissingSalary: boolean; missingSalaryPenalty: number },
): Promise<SuitabilityResult> {
  const jd = (job.jobDescription || "").toLowerCase();
  const title = job.title.toLowerCase();

  let score = 30;

  const titleHasQa = MOCK_TITLE_BOOST_KEYWORDS.some((kw) => title.includes(kw));
  if (titleHasQa) score += 25;

  for (const kw of MOCK_JD_BOOST_KEYWORDS) {
    if (jd.includes(kw)) score += 3;
  }

  for (const kw of MOCK_PENALTY_KEYWORDS) {
    if (title.includes(kw)) score -= 15;
  }

  score = Math.min(100, Math.max(0, score));

  const baseReason = "Scored using keyword matching (LLM unavailable)";

  const penaltyResult = applySalaryPenalty(job, score, baseReason, settings);

  return {
    score: penaltyResult.score,
    reason: penaltyResult.reason,
    analysis: null,
  };
}

/**
 * Score multiple jobs and return sorted by score (descending).
 */
export async function scoreAndRankJobs(
  jobs: Job[],
  profile: Record<string, unknown>,
): Promise<
  Array<
    Job & {
      suitabilityScore: number;
      suitabilityReason: string;
      suitabilityAnalysis: string | null;
    }
  >
> {
  const scoredJobs = await Promise.all(
    jobs.map(async (job) => {
      const { score, reason, analysis } = await scoreJobSuitability(
        job,
        profile,
      );
      return {
        ...job,
        suitabilityScore: score,
        suitabilityReason: reason,
        suitabilityAnalysis: analysis ? JSON.stringify(analysis) : null,
      };
    }),
  );

  return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
}
