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

import { logger } from "@infra/logger";
import type { Job, JobSearchProfile, SuitabilityAnalysis } from "@shared/types";
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)",
      },
      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:
          "Any deal-breakers from the candidate profile that this job triggers",
      },
    },
    required: [
      "score",
      "reason",
      "roleTypeMatch",
      "strengths",
      "gaps",
      "suggestions",
      "dealBreakerHits",
    ],
    additionalProperties: false,
  },
};

interface ScoringLlmResponse {
  score: number;
  reason: string;
  roleTypeMatch?: 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 {
  if (!data.strengths && !data.gaps && !data.suggestions) return null;

  return {
    roleTypeMatch:
      typeof data.roleTypeMatch === "number"
        ? Math.min(100, Math.max(0, Math.round(data.roleTypeMatch)))
        : 50,
    strengths: Array.isArray(data.strengths) ? data.strengths : [],
    gaps: Array.isArray(data.gaps) ? data.gaps : [],
    suggestions: Array.isArray(data.suggestions) ? data.suggestions : [],
    dealBreakerHits: Array.isArray(data.dealBreakerHits)
      ? data.dealBreakerHits
      : [],
  };
}

/**
 * Score a job's suitability based on profile and job description.
 * Includes retry logic for when AI returns garbage responses.
 */
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);

  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,
    });
  }

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

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

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

function hasNonEmptyProfile(p: JobSearchProfile): boolean {
  return (
    p.targetRoles.length > 0 ||
    p.mustHaveSkills.length > 0 ||
    p.dealBreakers.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):
- If the job's primary role type fundamentally mismatches the candidate's target roles, score MUST be 0-20.
  Example: If candidate wants "automation tester" roles, a "Full Stack Developer" job should score very low
  even if the description mentions testing tools. The JOB TITLE and PRIMARY RESPONSIBILITIES matter most.
- If any deal-breaker keywords appear in the job title or core requirements, score MUST be 0-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.`
    : "";

  const scoringCriteria = hasProfilePrefs
    ? `SCORING CRITERIA (with candidate preferences):
- Role type alignment with target roles: 0-35 points (MOST IMPORTANT - is this the KIND of job they want?)
- Skills match (must-haves weighted 3x, nice-to-haves 1x): 0-25 points
- Experience level match: 0-15 points
- Location/remote work alignment with preferences: 0-10 points
- Industry/domain fit: 0-10 points
- Career growth and salary alignment: 0-5 points`
    : `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>, "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.
- "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.

EXAMPLE VALID RESPONSE:
{"score": 25, "reason": "This is a full-stack developer role but the candidate is targeting automation testing positions. The mention of Playwright in the description is minor and not the core focus.", "roleTypeMatch": 15, "strengths": ["Has Playwright experience mentioned in the job description", "Located in the same city"], "gaps": ["No React/Node.js full-stack experience", "Job requires 3+ years of backend development"], "suggestions": ["If interested in full-stack, build portfolio projects with React and Node.js", "Consider SDET roles that bridge testing and development"], "dealBreakerHits": ["Role type mismatch: Full Stack Developer vs target of Automation Tester"]}`;
}

export function sanitizeProfileForPrompt(
  profile: Record<string, unknown>,
): Record<string, unknown> {
  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, 5)
    : [];
  const projectItems = Array.isArray(p.sections?.projects?.items)
    ? p.sections?.projects?.items.slice(0, 6)
    : [];

  return {
    basics: {
      label: p.basics?.label,
      summary: p.basics?.summary,
    },
    skills: p.sections?.skills ?? null,
    experience: experienceItems,
    projects: projectItems,
    education: p.sections?.education?.items ?? [],
  };
}

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

  const goodKeywords = [
    "typescript",
    "react",
    "node",
    "python",
    "web",
    "frontend",
    "backend",
    "fullstack",
    "software",
    "engineer",
    "developer",
  ];
  const badKeywords = [
    "senior",
    "5+ years",
    "10+ years",
    "principal",
    "staff",
    "manager",
  ];

  let score = 50;

  for (const kw of goodKeywords) {
    if (jd.includes(kw) || title.includes(kw)) score += 5;
  }

  for (const kw of badKeywords) {
    if (jd.includes(kw) || title.includes(kw)) score -= 10;
  }

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

  const baseReason = "Scored using keyword matching (API key not configured)";

  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);
}
