/**
 * Job repository - data access layer for jobs.
 */

import { randomUUID } from "node:crypto";
import { canonicalizeJobUrl } from "@shared/job-url-canonical";
import type {
  CreateJobInput,
  Job,
  JobListItem,
  JobStatus,
  JobsRevisionResponse,
  UpdateJobInput,
} from "@shared/types";
import { and, desc, eq, inArray, isNull, lt, ne, sql } from "drizzle-orm";
import { db, schema } from "../db/index";

const { jobs } = schema;

function normalizeCreateJobInputForDedup(
  input: CreateJobInput,
): CreateJobInput {
  const jobUrl = canonicalizeJobUrl(input.jobUrl);
  if (jobUrl === input.jobUrl) return input;
  return { ...input, jobUrl };
}

function sourceJobKey(source: string, sourceJobId: string): string {
  return `${source}\0${sourceJobId}`;
}

async function loadJobDedupIndexes(): Promise<{
  existingCanonicalSet: Set<string>;
  existingSourceJobKeySet: Set<string>;
}> {
  const rows = await db
    .select({
      jobUrl: jobs.jobUrl,
      source: jobs.source,
      sourceJobId: jobs.sourceJobId,
    })
    .from(jobs);

  const existingCanonicalSet = new Set(
    rows.map((r) => canonicalizeJobUrl(r.jobUrl)),
  );
  const existingSourceJobKeySet = new Set(
    rows
      .filter(
        (r) => r.sourceJobId != null && String(r.sourceJobId).trim().length > 0,
      )
      .map((r) => sourceJobKey(r.source, String(r.sourceJobId))),
  );
  return { existingCanonicalSet, existingSourceJobKeySet };
}

async function findJobByCanonicalUrl(canonical: string): Promise<Job | null> {
  const [exact] = await db
    .select()
    .from(jobs)
    .where(eq(jobs.jobUrl, canonical));
  if (exact) return mapRowToJob(exact);

  const allRows = await db.select().from(jobs);
  for (const row of allRows) {
    if (canonicalizeJobUrl(row.jobUrl) === canonical) {
      return mapRowToJob(row);
    }
  }
  return null;
}

async function getJobBySourceAndExternalId(
  source: string,
  sourceJobId: string,
): Promise<Job | null> {
  const [row] = await db
    .select()
    .from(jobs)
    .where(and(eq(jobs.source, source), eq(jobs.sourceJobId, sourceJobId)));
  return row ? mapRowToJob(row) : null;
}

function normalizeStatusFilter(statuses?: JobStatus[]): string | null {
  if (!statuses || statuses.length === 0) return null;
  return Array.from(new Set(statuses)).sort().join(",");
}

/**
 * Get all jobs, optionally filtered by status.
 */
export async function getAllJobs(statuses?: JobStatus[]): Promise<Job[]> {
  const query =
    statuses && statuses.length > 0
      ? db
          .select()
          .from(jobs)
          .where(inArray(jobs.status, statuses))
          .orderBy(desc(jobs.discoveredAt))
      : db.select().from(jobs).orderBy(desc(jobs.discoveredAt));

  const rows = await query;
  return rows.map(mapRowToJob);
}

/**
 * Get lightweight list items for jobs, optionally filtered by status.
 */
export async function getJobListItems(
  statuses?: JobStatus[],
): Promise<JobListItem[]> {
  const selection = {
    id: jobs.id,
    source: jobs.source,
    title: jobs.title,
    employer: jobs.employer,
    jobUrl: jobs.jobUrl,
    applicationLink: jobs.applicationLink,
    datePosted: jobs.datePosted,
    deadline: jobs.deadline,
    salary: jobs.salary,
    location: jobs.location,
    status: jobs.status,
    outcome: jobs.outcome,
    closedAt: jobs.closedAt,
    suitabilityScore: jobs.suitabilityScore,
    sponsorMatchScore: jobs.sponsorMatchScore,
    jobType: jobs.jobType,
    jobFunction: jobs.jobFunction,
    salaryMinAmount: jobs.salaryMinAmount,
    salaryMaxAmount: jobs.salaryMaxAmount,
    salaryCurrency: jobs.salaryCurrency,
    isRemote: jobs.isRemote,
    discoveredAt: jobs.discoveredAt,
    appliedAt: jobs.appliedAt,
    updatedAt: jobs.updatedAt,
  } as const;

  const query =
    statuses && statuses.length > 0
      ? db
          .select(selection)
          .from(jobs)
          .where(inArray(jobs.status, statuses))
          .orderBy(desc(jobs.discoveredAt))
      : db.select(selection).from(jobs).orderBy(desc(jobs.discoveredAt));

  const rows = await query;
  return rows.map((row) => ({
    ...row,
    source: row.source as JobListItem["source"],
    status: row.status as JobStatus,
  }));
}

/**
 * Get a lightweight revision token for jobs list invalidation.
 */
export async function getJobsRevision(
  statuses?: JobStatus[],
): Promise<JobsRevisionResponse> {
  const statusFilter = normalizeStatusFilter(statuses);
  const whereClause =
    statuses && statuses.length > 0
      ? inArray(jobs.status, statuses)
      : undefined;

  const baseQuery = db
    .select({
      latestUpdatedAt: sql<string | null>`max(${jobs.updatedAt})`,
      total: sql<number>`count(*)`,
    })
    .from(jobs);
  const [row] = whereClause
    ? await baseQuery.where(whereClause)
    : await baseQuery;

  const latestUpdatedAt = row?.latestUpdatedAt ?? null;
  const total = row?.total ?? 0;
  const revision = `${latestUpdatedAt ?? "none"}:${total}:${statusFilter ?? "all"}`;

  return {
    revision,
    latestUpdatedAt,
    total,
    statusFilter,
  };
}

/**
 * Get a single job by ID.
 */
export async function getJobById(id: string): Promise<Job | null> {
  const [row] = await db.select().from(jobs).where(eq(jobs.id, id));
  return row ? mapRowToJob(row) : null;
}

export async function listJobSummariesByIds(jobIds: string[]): Promise<
  Array<{
    id: string;
    title: string;
    employer: string;
  }>
> {
  if (jobIds.length === 0) return [];

  return db
    .select({
      id: jobs.id,
      title: jobs.title,
      employer: jobs.employer,
    })
    .from(jobs)
    .where(inArray(jobs.id, jobIds));
}

/**
 * Get a job by its URL (for deduplication).
 * Matches canonical URL equivalence, including legacy rows stored with non-canonical URLs.
 */
export async function getJobByUrl(jobUrl: string): Promise<Job | null> {
  return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl));
}

/**
 * Get all known canonical job URLs (for deduplication / crawler skip lists).
 */
export async function getAllJobUrls(): Promise<string[]> {
  const rows = await db.select({ jobUrl: jobs.jobUrl }).from(jobs);
  const canonicals = rows.map((r) => canonicalizeJobUrl(r.jobUrl));
  return Array.from(new Set(canonicals));
}

async function insertJob(input: CreateJobInput): Promise<Job> {
  const id = randomUUID();
  const now = new Date().toISOString();

  await db.insert(jobs).values({
    id,
    source: input.source,
    sourceJobId: input.sourceJobId ?? null,
    jobUrlDirect: input.jobUrlDirect ?? null,
    datePosted: input.datePosted ?? null,
    title: input.title,
    employer: input.employer,
    employerUrl: input.employerUrl ?? null,
    jobUrl: input.jobUrl,
    applicationLink: input.applicationLink ?? null,
    disciplines: input.disciplines ?? null,
    deadline: input.deadline ?? null,
    salary: input.salary ?? null,
    location: input.location ?? null,
    degreeRequired: input.degreeRequired ?? null,
    starting: input.starting ?? null,
    jobDescription: input.jobDescription ?? null,
    jobType: input.jobType ?? null,
    salarySource: input.salarySource ?? null,
    salaryInterval: input.salaryInterval ?? null,
    salaryMinAmount: input.salaryMinAmount ?? null,
    salaryMaxAmount: input.salaryMaxAmount ?? null,
    salaryCurrency: input.salaryCurrency ?? null,
    isRemote: input.isRemote ?? null,
    jobLevel: input.jobLevel ?? null,
    jobFunction: input.jobFunction ?? null,
    listingType: input.listingType ?? null,
    emails: input.emails ?? null,
    companyIndustry: input.companyIndustry ?? null,
    companyLogo: input.companyLogo ?? null,
    companyUrlDirect: input.companyUrlDirect ?? null,
    companyAddresses: input.companyAddresses ?? null,
    companyNumEmployees: input.companyNumEmployees ?? null,
    companyRevenue: input.companyRevenue ?? null,
    companyDescription: input.companyDescription ?? null,
    skills: input.skills ?? null,
    experienceRange: input.experienceRange ?? null,
    companyRating: input.companyRating ?? null,
    companyReviewsCount: input.companyReviewsCount ?? null,
    vacancyCount: input.vacancyCount ?? null,
    workFromHomeType: input.workFromHomeType ?? null,
    status: "discovered",
    discoveredAt: now,
    createdAt: now,
    updatedAt: now,
  });

  const job = await getJobById(id);
  if (!job) {
    throw new Error(`Failed to retrieve newly created job with ID ${id}`);
  }
  return job;
}

function isJobUrlUniqueViolation(error: unknown): boolean {
  if (!(error instanceof Error)) return false;
  return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message);
}

async function tryInsertJob(input: CreateJobInput): Promise<Job | null> {
  try {
    return await insertJob(input);
  } catch (error) {
    if (isJobUrlUniqueViolation(error)) return null;
    throw error;
  }
}

/**
 * Create jobs (or return existing jobs for duplicate URLs).
 */
export async function createJobs(input: CreateJobInput): Promise<Job>;
export async function createJobs(
  inputs: CreateJobInput[],
): Promise<{ created: number; skipped: number }>;
export async function createJobs(
  inputOrInputs: CreateJobInput | CreateJobInput[],
): Promise<Job | { created: number; skipped: number }> {
  if (!Array.isArray(inputOrInputs)) {
    const normalized = normalizeCreateJobInputForDedup(inputOrInputs);
    const { existingCanonicalSet, existingSourceJobKeySet } =
      await loadJobDedupIndexes();

    const sid = normalized.sourceJobId?.trim();
    if (sid) {
      const sk = sourceJobKey(normalized.source, sid);
      if (existingSourceJobKeySet.has(sk)) {
        const existing = await getJobBySourceAndExternalId(
          normalized.source,
          sid,
        );
        if (existing) return existing;
      }
    }

    if (existingCanonicalSet.has(normalized.jobUrl)) {
      const existing = await findJobByCanonicalUrl(normalized.jobUrl);
      if (existing) return existing;
    }

    const inserted = await tryInsertJob(normalized);
    if (inserted) return inserted;

    const existingAfterConflict =
      (await findJobByCanonicalUrl(normalized.jobUrl)) ??
      (sid ? await getJobBySourceAndExternalId(normalized.source, sid) : null);
    if (existingAfterConflict) return existingAfterConflict;

    throw new Error("Failed to create or resolve existing job by URL");
  }

  const { existingCanonicalSet, existingSourceJobKeySet } =
    await loadJobDedupIndexes();

  const batchBuckets = new Map<
    string,
    {
      input: CreateJobInput;
      count: number;
    }
  >();

  for (const raw of inputOrInputs) {
    const normalized = normalizeCreateJobInputForDedup(raw);
    const batchKey = normalized.sourceJobId?.trim()
      ? `sid:${sourceJobKey(normalized.source, normalized.sourceJobId!)}`
      : `url:${normalized.jobUrl}`;
    const prev = batchBuckets.get(batchKey);
    if (prev) {
      prev.count += 1;
    } else {
      batchBuckets.set(batchKey, { input: normalized, count: 1 });
    }
  }

  let created = 0;
  let skipped = 0;

  for (const { input, count } of batchBuckets.values()) {
    const canonical = input.jobUrl;
    const sid = input.sourceJobId?.trim();
    const sk = sid ? sourceJobKey(input.source, sid) : null;

    if (sk && existingSourceJobKeySet.has(sk)) {
      skipped += count;
      continue;
    }
    if (existingCanonicalSet.has(canonical)) {
      skipped += count;
      continue;
    }

    const inserted = await tryInsertJob(input);
    if (!inserted) {
      skipped += count;
      continue;
    }

    created += 1;
    skipped += count - 1;
    existingCanonicalSet.add(canonical);
    if (sk) {
      existingSourceJobKeySet.add(sk);
    }
  }

  return { created, skipped };
}

/**
 * Create a single job (or return existing if URL matches).
 */
export async function createJob(input: CreateJobInput): Promise<Job> {
  return createJobs(input);
}

/**
 * Update a job.
 */
export async function updateJob(
  id: string,
  input: UpdateJobInput,
): Promise<Job | null> {
  const now = new Date().toISOString();

  await db
    .update(jobs)
    .set({
      ...input,
      updatedAt: now,
      ...(input.status === "processing" ? { processedAt: now } : {}),
      ...(input.status === "applied" && !input.appliedAt
        ? { appliedAt: now }
        : {}),
    })
    .where(eq(jobs.id, id));

  return getJobById(id);
}

/**
 * Get job statistics by status.
 */
export async function getJobStats(): Promise<Record<JobStatus, number>> {
  const result = await db
    .select({
      status: jobs.status,
      count: sql<number>`count(*)`,
    })
    .from(jobs)
    .groupBy(jobs.status);

  const stats: Record<JobStatus, number> = {
    discovered: 0,
    processing: 0,
    ready: 0,
    applied: 0,
    in_progress: 0,
    skipped: 0,
    expired: 0,
  };

  for (const row of result) {
    stats[row.status as JobStatus] = row.count;
  }

  return stats;
}

/**
 * Get jobs ready for processing (discovered with description).
 */
export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
  const rows = await db
    .select()
    .from(jobs)
    .where(
      and(
        eq(jobs.status, "discovered"),
        sql`${jobs.jobDescription} IS NOT NULL`,
      ),
    )
    .orderBy(desc(jobs.discoveredAt))
    .limit(limit);

  return rows.map(mapRowToJob);
}

/**
 * Get discovered jobs missing a suitability score.
 */
export async function getUnscoredDiscoveredJobs(
  limit?: number,
): Promise<Job[]> {
  const query = db
    .select()
    .from(jobs)
    .where(and(eq(jobs.status, "discovered"), isNull(jobs.suitabilityScore)))
    .orderBy(desc(jobs.discoveredAt));

  const rows =
    typeof limit === "number" ? await query.limit(limit) : await query;
  return rows.map(mapRowToJob);
}

/**
 * Delete jobs by status.
 */
export async function deleteJobsByStatus(status: JobStatus): Promise<number> {
  const result = await db.delete(jobs).where(eq(jobs.status, status)).run();
  return result.changes;
}

/**
 * Delete jobs with suitability score below threshold (excluding applied and in_progress jobs).
 */
export async function deleteJobsBelowScore(threshold: number): Promise<number> {
  const result = await db
    .delete(jobs)
    .where(
      and(
        lt(jobs.suitabilityScore, threshold),
        ne(jobs.status, "applied"),
        ne(jobs.status, "in_progress"),
      ),
    )
    .run();
  return result.changes;
}

// Helper to map database row to Job type
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
  return {
    id: row.id,
    source: row.source as Job["source"],
    sourceJobId: row.sourceJobId ?? null,
    jobUrlDirect: row.jobUrlDirect ?? null,
    datePosted: row.datePosted ?? null,
    title: row.title,
    employer: row.employer,
    employerUrl: row.employerUrl,
    jobUrl: row.jobUrl,
    applicationLink: row.applicationLink,
    disciplines: row.disciplines,
    deadline: row.deadline,
    salary: row.salary,
    location: row.location,
    degreeRequired: row.degreeRequired,
    starting: row.starting,
    jobDescription: row.jobDescription,
    status: row.status as JobStatus,
    outcome: row.outcome ?? null,
    closedAt: row.closedAt ?? null,
    suitabilityScore: row.suitabilityScore,
    suitabilityReason: row.suitabilityReason,
    suitabilityAnalysis: row.suitabilityAnalysis ?? null,
    coverLetter: row.coverLetter ?? null,
    tailoredSummary: row.tailoredSummary,
    tailoredHeadline: row.tailoredHeadline ?? null,
    tailoredSkills: row.tailoredSkills ?? null,
    selectedProjectIds: row.selectedProjectIds ?? null,
    pdfPath: row.pdfPath,
    tracerLinksEnabled: row.tracerLinksEnabled ?? false,
    sponsorMatchScore: row.sponsorMatchScore ?? null,
    sponsorMatchNames: row.sponsorMatchNames ?? null,
    jobType: row.jobType ?? null,
    salarySource: row.salarySource ?? null,
    salaryInterval: row.salaryInterval ?? null,
    salaryMinAmount: row.salaryMinAmount ?? null,
    salaryMaxAmount: row.salaryMaxAmount ?? null,
    salaryCurrency: row.salaryCurrency ?? null,
    isRemote: row.isRemote ?? null,
    jobLevel: row.jobLevel ?? null,
    jobFunction: row.jobFunction ?? null,
    listingType: row.listingType ?? null,
    emails: row.emails ?? null,
    companyIndustry: row.companyIndustry ?? null,
    companyLogo: row.companyLogo ?? null,
    companyUrlDirect: row.companyUrlDirect ?? null,
    companyAddresses: row.companyAddresses ?? null,
    companyNumEmployees: row.companyNumEmployees ?? null,
    companyRevenue: row.companyRevenue ?? null,
    companyDescription: row.companyDescription ?? null,
    skills: row.skills ?? null,
    experienceRange: row.experienceRange ?? null,
    companyRating: row.companyRating ?? null,
    companyReviewsCount: row.companyReviewsCount ?? null,
    vacancyCount: row.vacancyCount ?? null,
    workFromHomeType: row.workFromHomeType ?? null,
    discoveredAt: row.discoveredAt,
    processedAt: row.processedAt,
    appliedAt: row.appliedAt,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  };
}
