import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { PoolClient } from "pg";
import { pool } from "../db/client.js";
import { storageProvider } from "../storage/index.js";
import type { StorageObjectSummary } from "../storage/StorageProvider.js";

type ProjectSourceForScan = {
  project_id: string;
  source_id: string;
  source_type: string;
  source_config: Record<string, unknown>;
};

type ScanVersionRow = {
  id: string;
  project_id: string;
  source_id: string;
  status: string;
  project_index_key: string | null;
  started_at: Date | null;
  completed_at: Date | null;
  error_message: string | null;
  created_at: Date;
};

type PageVersionRow = {
  page_id: string;
  selected: boolean;
  source_path: string;
  url_path: string | null;
  title: string | null;
  description: string | null;
  fingerprint: string | null;
  reconcile_status: string;
  raw: Record<string, unknown>;
};

type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";
type PagePreviewKind = "page" | "home_section" | "raw_template" | "none";

type HomeBlockStructure = {
  adminLabel: string | null;
  anchor: string | null;
  enabled: boolean;
  id: string | null;
  order: number;
  previewTargetIndex: number | null;
  previewTargetSelector: string | null;
  regionId: string | null;
  regionLabel: string | null;
  template: string;
};

type HomeStructure = {
  sections: HomeBlockStructure[];
  outputPath: string;
  byTemplate: Map<string, HomeBlockStructure[]>;
};

type MediaVersionRow = {
  media_id: string;
  source_path: string;
  media_type: string;
  reconcile_status: string;
  raw: Record<string, unknown>;
};

type BaselineAuditRunRow = {
  id: string;
  status: string;
  output: BaselineAuditOutput | null;
  error_message: string | null;
  started_at: Date | null;
  completed_at: Date | null;
  created_at: Date;
};

type SourceFile = {
  sourcePath: string;
  projectPath: string;
  storageKey?: string;
  absolutePath?: string;
  size: number;
  lastModified?: string;
  etag?: string;
};

type PageCandidate = {
  logicalKey: string;
  sourcePath: string;
  urlPath: string;
  title: string | null;
  description: string | null;
  fingerprint: string;
  raw: Record<string, unknown>;
};

type AssetCandidate = {
  logicalKey: string;
  sourcePath: string;
  mediaType: string;
  fingerprint: string;
  raw: Record<string, unknown>;
};

type HtmlMediaRef = {
  tag: "img" | "video";
  source: string | null;
  alt?: string | null;
  title?: string | null;
  ariaLabel?: string | null;
  poster?: string | null;
};

type BaselineAuditIssue = {
  id: string;
  severity: "error" | "warning" | "info";
  scope: "page" | "media" | "project";
  code: string;
  message: string;
  pageId?: string;
  mediaId?: string;
  sourcePath?: string;
  urlPath?: string | null;
  field?: string;
};

type BaselineAuditOutput = {
  scanVersionId: string;
  summary: {
    pagesChecked: number;
    assetsChecked: number;
    missingTitle: number;
    missingDescription: number;
    missingH1: number;
    mediaWarnings: number;
  };
  counts: {
    total: number;
    errors: number;
    warnings: number;
    info: number;
  };
  issues: BaselineAuditIssue[];
};

type BaselineAuditSummary = BaselineAuditOutput & {
  runId: string;
  status: string;
  startedAt: string | null;
  completedAt: string | null;
  errorMessage: string | null;
  createdAt: string;
};

export type ProjectScanSummary = {
  id: string;
  projectId: string;
  sourceId: string;
  status: string;
  projectIndexKey: string | null;
  startedAt: string | null;
  completedAt: string | null;
  errorMessage: string | null;
  createdAt: string;
  counts: {
    pages: number;
    assets: number;
  };
  audit: BaselineAuditSummary | null;
  pages: Array<{
    pageId: string;
    selected: boolean;
    sourcePath: string;
    urlPath: string | null;
    title: string | null;
    description: string | null;
    fingerprint: string | null;
    reconcileStatus: string;
    scope: PageScope;
    scopeReason: string;
    previewSourcePath: string | null;
    previewTargetSelector: string | null;
    previewKind: PagePreviewKind;
    structureLabel: string | null;
    structureOrder: number | null;
    visibleInSite: boolean | null;
    usedForCoverage: boolean;
    headings: Array<{ level: number; text: string }>;
  }>;
  siteSections: Array<{
    sectionId: string;
    pageId: string | null;
    selected: boolean;
    title: string;
    sourcePath: string;
    template: string;
    enabled: boolean;
    order: number;
    regionId: string | null;
    regionLabel: string | null;
    previewSourcePath: string | null;
    previewTargetSelector: string | null;
    previewTargetIndex: number | null;
    previewKind: PagePreviewKind;
    scopeReason: string;
    headings: Array<{ level: number; text: string }>;
  }>;
  assets: Array<{
    mediaId: string;
    sourcePath: string;
    mediaType: string;
    size: number | null;
    reconcileStatus: string;
  }>;
};

export class ProjectScanError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ProjectScanError";
  }
}

const HTML_EXTENSIONS = new Set([".html", ".htm"]);
const SKIPPED_LOCAL_DIRECTORIES = new Set([".git", "node_modules", ".next", ".cache", ".turbo"]);

const ASSET_TYPES: Array<{ type: string; extensions: string[] }> = [
  { type: "image", extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico"] },
  { type: "video", extensions: [".mp4", ".webm", ".mov", ".avi", ".m4v"] },
  { type: "audio", extensions: [".mp3", ".wav", ".ogg", ".m4a"] },
  { type: "style", extensions: [".css"] },
  { type: "script", extensions: [".js", ".mjs", ".cjs"] },
  { type: "font", extensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"] },
  { type: "document", extensions: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"] },
  { type: "data", extensions: [".json", ".xml", ".webmanifest"] }
];

function toIsoDate(value: Date | null) {
  return value ? value.toISOString() : null;
}

function normalizePath(value: string) {
  return value.replace(/\\/g, "/").replace(/^\/+/, "");
}

function getStringConfig(config: Record<string, unknown>, key: string) {
  const value = config[key];
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}

function asRecord(value: unknown): Record<string, unknown> | null {
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}

function asArray(value: unknown): unknown[] {
  return Array.isArray(value) ? value : [];
}

function getRecordString(record: Record<string, unknown>, key: string) {
  const value = record[key];
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}

function getRecordBoolean(record: Record<string, unknown>, key: string, fallback: boolean) {
  const value = record[key];
  return typeof value === "boolean" ? value : fallback;
}

function stripRootName(relativePath: string, rootName: string | null) {
  const normalizedPath = normalizePath(relativePath);
  const normalizedRoot = rootName ? normalizePath(rootName) : null;

  if (normalizedRoot && normalizedPath.toLocaleLowerCase().startsWith(`${normalizedRoot.toLocaleLowerCase()}/`)) {
    return normalizedPath.slice(normalizedRoot.length + 1);
  }

  return normalizedPath;
}

function urlPathFromProjectPath(projectPath: string) {
  const normalizedPath = normalizePath(projectPath);

  if (/^index\.html?$/i.test(normalizedPath)) {
    return "/";
  }

  if (/\/index\.html?$/i.test(normalizedPath)) {
    return `/${normalizedPath.replace(/index\.html?$/i, "")}`;
  }

  return `/${normalizedPath}`;
}

function classifyScannedPageScope(page: {
  sourcePath?: string;
  source_path?: string;
  urlPath?: string | null;
  url_path?: string | null;
  title?: string | null;
  description?: string | null;
}): { scope: PageScope; reason: string } {
  const sourcePath = normalizePath(page.sourcePath ?? page.source_path ?? "").toLocaleLowerCase("ru-RU");
  const urlPath = (page.urlPath ?? page.url_path ?? "").toLocaleLowerCase("ru-RU");

  if (sourcePath.startsWith("admin/") || urlPath.startsWith("/admin")) {
    return {
      scope: "admin_page",
      reason: "Админка исключена из органического SEO-покрытия."
    };
  }

  if (
    sourcePath.startsWith("templates/") ||
    sourcePath.includes("/blocks/") ||
    /\{\{[^}]+}}/.test(page.title ?? "") ||
    /\{\{[^}]+}}/.test(page.description ?? "")
  ) {
    return {
      scope: "template_block",
      reason: "Шаблон или HTML-блок учитывается как технический контекст, но не как посадочная."
    };
  }

  if (sourcePath.startsWith("src/") || sourcePath.startsWith("app/") || sourcePath.startsWith("components/")) {
    return {
      scope: "technical_page",
      reason: "Техническая HTML-заготовка исключена из SEO-покрытия."
    };
  }

  return {
    scope: "indexable_page",
    reason: "Публичная HTML-страница, которую можно брать в SEO-покрытие."
  };
}

const HOME_TEMPLATE_SELECTORS: Record<string, string> = {
  "central-text-block.html": ".central-text-block",
  "demo-video-folder.html": ".folder-w.video-w",
  "faq.html": "section.s:has(#faq-item)",
  "footer.html": "footer",
  "pricing-intro.html": "#pricing",
  "pricing-table.html": "#pricing-wrap",
  "project-composition.html": ".project-composition",
  "project-video-detail.html": ".project-video-detail",
  "project-video-first.html": "section.mobile-flip",
  "publish-cta.html": "section.s:has(.form-btn.static.mt-10)",
  "request-demo-txt-files.html": "#waitlist",
  "screenshot-cards.html": ".component-lib",
  "theses-request-demo.html": "#hero"
};

function normalizeTemplateName(value: string) {
  return path.posix.basename(normalizePath(value)).toLocaleLowerCase("ru-RU");
}

function selectorFromAnchor(anchor: string | null) {
  if (!anchor || !/^[a-zA-Z][\w:-]*$/.test(anchor)) {
    return null;
  }

  return `#${anchor}`;
}

function selectorForHomeBlock(block: HomeBlockStructure) {
  return block.previewTargetSelector ?? HOME_TEMPLATE_SELECTORS[normalizeTemplateName(block.template)] ?? selectorFromAnchor(block.anchor);
}

function parseHomeStructure(rawText: string | null): HomeStructure | null {
  if (!rawText) {
    return null;
  }

  let model: unknown;

  try {
    model = JSON.parse(rawText);
  } catch {
    return null;
  }

  const root = asRecord(model);

  if (!root) {
    return null;
  }

  const outputPath = normalizePath(getRecordString(root, "output") ?? "index.html");
  const byTemplate = new Map<string, HomeBlockStructure[]>();
  const sections: HomeBlockStructure[] = [];
  const enabledSelectorCounts = new Map<string, number>();
  let order = 0;

  for (const regionValue of asArray(root.regions)) {
    const region = asRecord(regionValue);

    if (!region) {
      continue;
    }

    const regionId = getRecordString(region, "id");
    const regionLabel = getRecordString(region, "label") ?? getRecordString(region, "adminLabel");

    for (const blockValue of asArray(region.blocks)) {
      const block = asRecord(blockValue);

      if (!block) {
        continue;
      }

      const template = getRecordString(block, "template");

      if (!template) {
        continue;
      }

      const normalizedTemplate = normalizeTemplateName(template);
      const enabled = getRecordBoolean(block, "enabled", true);
      const anchor = getRecordString(block, "anchor");
      const selectorSeed = HOME_TEMPLATE_SELECTORS[normalizedTemplate] ?? selectorFromAnchor(anchor);
      const previewTargetIndex = enabled && selectorSeed ? enabledSelectorCounts.get(selectorSeed) ?? 0 : null;
      const item: HomeBlockStructure = {
        adminLabel: getRecordString(block, "adminLabel"),
        anchor,
        enabled,
        id: getRecordString(block, "id"),
        order,
        previewTargetIndex,
        previewTargetSelector: selectorSeed,
        regionId,
        regionLabel,
        template
      };
      const templateBlocks = byTemplate.get(normalizedTemplate) ?? [];

      templateBlocks.push(item);
      byTemplate.set(normalizedTemplate, templateBlocks);
      sections.push(item);
      if (enabled && selectorSeed) {
        enabledSelectorCounts.set(selectorSeed, (enabledSelectorCounts.get(selectorSeed) ?? 0) + 1);
      }
      order += 1;
    }
  }

  return {
    sections,
    outputPath,
    byTemplate
  };
}

function summarizeTemplateLabel(blocks: HomeBlockStructure[]) {
  if (blocks.length === 0) {
    return null;
  }

  const enabledBlocks = blocks.filter((block) => block.enabled);
  const labelSource = enabledBlocks[0] ?? blocks[0];
  const baseLabel = labelSource?.adminLabel ?? labelSource?.id ?? null;

  if (!baseLabel) {
    return null;
  }

  if (blocks.length <= 1) {
    return baseLabel;
  }

  return `${baseLabel} + ${blocks.length - 1}`;
}

function getTemplatePreviewMeta(sourcePath: string, homeStructure: HomeStructure | null) {
  const normalizedTemplate = normalizeTemplateName(sourcePath);
  const blocks = homeStructure?.byTemplate.get(normalizedTemplate) ?? [];
  const enabledBlocks = blocks.filter((block) => block.enabled);
  const primaryBlock = enabledBlocks[0] ?? blocks[0] ?? null;
  const structureLabel = summarizeTemplateLabel(blocks);
  const structureOrder = blocks.length > 0 ? Math.min(...blocks.map((block) => block.order)) : null;

  if (!homeStructure || !primaryBlock) {
    return {
      previewKind: "raw_template" as const,
      previewSourcePath: null,
      previewTargetSelector: null,
      structureLabel,
      structureOrder,
      visibleInSite: null
    };
  }

  if (enabledBlocks.length === 0) {
    return {
      previewKind: "none" as const,
      previewSourcePath: null,
      previewTargetSelector: null,
      structureLabel,
      structureOrder,
      visibleInSite: false
    };
  }

  return {
    previewKind: "home_section" as const,
    previewSourcePath: homeStructure.outputPath,
    previewTargetSelector: selectorForHomeBlock(primaryBlock),
    structureLabel,
    structureOrder,
    visibleInSite: true
  };
}

function getPagePreviewMeta(row: PageVersionRow, pageScope: { scope: PageScope }, homeStructure: HomeStructure | null) {
  if (pageScope.scope === "template_block") {
    return getTemplatePreviewMeta(row.source_path, homeStructure);
  }

  return {
    previewKind: "page" as const,
    previewSourcePath: row.source_path,
    previewTargetSelector: null,
    structureLabel: null,
    structureOrder: null,
    visibleInSite: true
  };
}

function sourcePathForHomeTemplate(template: string) {
  return `templates/pages/home/blocks/${normalizeTemplateName(template)}`;
}

function buildSiteSections(homeStructure: HomeStructure | null, pages: ProjectScanSummary["pages"]) {
  if (!homeStructure) {
    return [];
  }

  const pagesBySourcePath = new Map(pages.map((page) => [normalizePath(page.sourcePath).toLocaleLowerCase("ru-RU"), page]));

  return homeStructure.sections.map((section) => {
    const sourcePath = sourcePathForHomeTemplate(section.template);
    const page = pagesBySourcePath.get(sourcePath.toLocaleLowerCase("ru-RU")) ?? null;
    const title = section.adminLabel ?? section.id ?? section.template;
    const hasPreview = section.enabled && Boolean(section.previewTargetSelector);

    return {
      sectionId: section.id ?? `home-section-${section.order}`,
      pageId: page?.pageId ?? null,
      selected: page?.selected ?? false,
      title,
      sourcePath,
      template: section.template,
      enabled: section.enabled,
      order: section.order,
      regionId: section.regionId,
      regionLabel: section.regionLabel,
      previewSourcePath: hasPreview ? homeStructure.outputPath : null,
      previewTargetSelector: hasPreview ? section.previewTargetSelector : null,
      previewTargetIndex: hasPreview ? section.previewTargetIndex : null,
      previewKind: hasPreview ? ("home_section" as const) : ("none" as const),
      scopeReason: section.enabled
        ? "Секция собранной главной страницы из content/pages/home.json."
        : "Секция есть в модели страницы, но сейчас отключена и не рендерится.",
      headings: page?.headings ?? []
    };
  });
}

function getExtension(sourcePath: string) {
  return path.extname(sourcePath).toLocaleLowerCase();
}

function getAssetType(sourcePath: string) {
  const extension = getExtension(sourcePath);
  const matchedType = ASSET_TYPES.find((assetType) => assetType.extensions.includes(extension));

  return matchedType?.type ?? null;
}

function hashValue(value: string) {
  return crypto.createHash("sha256").update(value).digest("hex");
}

function isValidCodePoint(value: number) {
  return Number.isInteger(value) && value >= 0 && value <= 0x10ffff;
}

function decodeHtmlEntities(value: string) {
  const namedEntities: Record<string, string> = {
    amp: "&",
    gt: ">",
    lt: "<",
    nbsp: " ",
    quot: "\""
  };

  return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity: string) => {
    const normalizedEntity = entity.toLocaleLowerCase();

    if (normalizedEntity.startsWith("#x")) {
      const codePoint = Number.parseInt(normalizedEntity.slice(2), 16);
      return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
    }

    if (normalizedEntity.startsWith("#")) {
      const codePoint = Number.parseInt(normalizedEntity.slice(1), 10);
      return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
    }

    return namedEntities[normalizedEntity] ?? match;
  });
}

function normalizeHtmlText(value: string) {
  return decodeHtmlEntities(value.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
}

function extractFirstTagText(html: string, tagName: string) {
  const match = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i").exec(html);

  return match ? normalizeHtmlText(match[1] ?? "") || null : null;
}

function getAttribute(attributes: string, attributeName: string) {
  const match = new RegExp(`${attributeName}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attributes);

  return match?.[2] ?? match?.[3] ?? match?.[4] ?? null;
}

function extractMetaDescription(html: string) {
  const metaPattern = /<meta\s+([^>]*?)>/gi;
  let match: RegExpExecArray | null;

  while ((match = metaPattern.exec(html))) {
    const attributes = match[1] ?? "";
    const name = getAttribute(attributes, "name") ?? getAttribute(attributes, "property");
    const content = getAttribute(attributes, "content");

    if (content && name && ["description", "og:description"].includes(name.toLocaleLowerCase())) {
      return normalizeHtmlText(content) || null;
    }
  }

  return null;
}

function extractHeadings(html: string) {
  const headingPattern = /<h([1-3])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
  const headings: Array<{ level: number; text: string }> = [];
  let match: RegExpExecArray | null;

  while ((match = headingPattern.exec(html)) && headings.length < 50) {
    const text = normalizeHtmlText(match[2] ?? "");

    if (text) {
      headings.push({
        level: Number(match[1]),
        text
      });
    }
  }

  return headings;
}

function extractLinkedAssets(html: string) {
  const linkPattern = /\b(?:src|href)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi;
  const links = new Set<string>();
  let match: RegExpExecArray | null;

  while ((match = linkPattern.exec(html)) && links.size < 200) {
    const value = match[2] ?? match[3] ?? match[4] ?? "";
    const normalizedValue = value.trim();

    if (
      normalizedValue &&
      !normalizedValue.startsWith("#") &&
      !/^(https?:|mailto:|tel:|data:)/i.test(normalizedValue)
    ) {
      links.add(normalizedValue);
    }
  }

  return Array.from(links);
}

function extractHtmlMediaRefs(html: string) {
  const mediaRefs: HtmlMediaRef[] = [];
  const imgPattern = /<img\s+([^>]*?)>/gi;
  const videoPattern = /<video\s+([^>]*?)>/gi;
  let match: RegExpExecArray | null;

  while ((match = imgPattern.exec(html)) && mediaRefs.length < 300) {
    const attributes = match[1] ?? "";

    mediaRefs.push({
      tag: "img",
      source: getAttribute(attributes, "src") ?? getAttribute(attributes, "data-src"),
      alt: getAttribute(attributes, "alt"),
      title: getAttribute(attributes, "title"),
      ariaLabel: getAttribute(attributes, "aria-label")
    });
  }

  while ((match = videoPattern.exec(html)) && mediaRefs.length < 300) {
    const attributes = match[1] ?? "";

    mediaRefs.push({
      tag: "video",
      source: getAttribute(attributes, "src"),
      poster: getAttribute(attributes, "poster"),
      title: getAttribute(attributes, "title"),
      ariaLabel: getAttribute(attributes, "aria-label")
    });
  }

  return mediaRefs;
}

function parseHtmlPage(file: SourceFile, html: string): PageCandidate {
  const urlPath = urlPathFromProjectPath(file.projectPath);
  const headings = extractHeadings(html);

  return {
    logicalKey: urlPath,
    sourcePath: file.sourcePath,
    urlPath,
    title: extractFirstTagText(html, "title"),
    description: extractMetaDescription(html),
    fingerprint: hashValue(html),
    raw: {
      headings,
      linkedAssets: extractLinkedAssets(html),
      mediaRefs: extractHtmlMediaRefs(html),
      bodyTextLength: normalizeHtmlText(html).length,
      size: file.size,
      lastModified: file.lastModified,
      etag: file.etag
    }
  };
}

function buildAssetCandidate(file: SourceFile, mediaType: string): AssetCandidate {
  const fingerprint = hashValue([file.projectPath, file.size, file.lastModified ?? "", file.etag ?? ""].join("|"));

  return {
    logicalKey: file.projectPath,
    sourcePath: file.sourcePath,
    mediaType,
    fingerprint,
    raw: {
      size: file.size,
      lastModified: file.lastModified,
      etag: file.etag,
      fingerprint
    }
  };
}

function buildProjectIndexArtifact(
  scan: ScanVersionRow,
  source: ProjectSourceForScan,
  files: SourceFile[],
  pages: PageCandidate[],
  assets: AssetCandidate[]
) {
  return {
    schemaVersion: "project-index.v1",
    projectId: scan.project_id,
    sourceId: scan.source_id,
    scanVersionId: scan.id,
    source: {
      type: source.source_type,
      config: source.source_config
    },
    generatedAt: new Date().toISOString(),
    counts: {
      files: files.length,
      pages: pages.length,
      assets: assets.length
    },
    files: files.map((file) => ({
      sourcePath: file.sourcePath,
      projectPath: file.projectPath,
      extension: getExtension(file.sourcePath),
      size: file.size,
      lastModified: file.lastModified,
      etag: file.etag
    })),
    pages: pages.map((page) => ({
      logicalKey: page.logicalKey,
      sourcePath: page.sourcePath,
      urlPath: page.urlPath,
      title: page.title,
      description: page.description,
      fingerprint: page.fingerprint,
      headings: page.raw.headings,
      linkedAssets: page.raw.linkedAssets,
      mediaRefs: page.raw.mediaRefs,
      bodyTextLength: page.raw.bodyTextLength
    })),
    assets: assets.map((asset) => ({
      logicalKey: asset.logicalKey,
      sourcePath: asset.sourcePath,
      mediaType: asset.mediaType,
      fingerprint: asset.fingerprint,
      size: asset.raw.size,
      lastModified: asset.raw.lastModified,
      etag: asset.raw.etag
    }))
  };
}

async function saveProjectIndexArtifact(
  scan: ScanVersionRow,
  source: ProjectSourceForScan,
  files: SourceFile[],
  pages: PageCandidate[],
  assets: AssetCandidate[]
) {
  const projectIndexKey = `projects/${scan.project_id}/scans/${scan.id}/project-index.json`;

  await storageProvider.putObject({
    key: projectIndexKey,
    body: JSON.stringify(buildProjectIndexArtifact(scan, source, files, pages, assets), null, 2),
    contentType: "application/json"
  });

  return projectIndexKey;
}

async function getProjectSource(projectId: string) {
  const result = await pool.query<ProjectSourceForScan>(
    `
      select
        p.id as project_id,
        ps.id as source_id,
        ps.type as source_type,
        ps.config as source_config
      from projects p
      join lateral (
        select id, type, config
        from project_sources
        where project_id = p.id
        order by created_at asc
        limit 1
      ) ps on true
      where p.id = $1;
    `,
    [projectId]
  );

  return result.rows[0] ?? null;
}

async function getProjectSourceById(sourceId: string) {
  const result = await pool.query<ProjectSourceForScan>(
    `
      select
        project_id,
        id as source_id,
        type as source_type,
        config as source_config
      from project_sources
      where id = $1;
    `,
    [sourceId]
  );

  return result.rows[0] ?? null;
}

async function walkLocalDirectory(rootPath: string, currentPath = ""): Promise<SourceFile[]> {
  const absoluteDirectory = path.join(rootPath, currentPath);
  const entries = await fs.readdir(absoluteDirectory, { withFileTypes: true });
  const files: SourceFile[] = [];

  for (const entry of entries) {
    if (entry.isDirectory()) {
      if (!SKIPPED_LOCAL_DIRECTORIES.has(entry.name)) {
        files.push(...(await walkLocalDirectory(rootPath, path.join(currentPath, entry.name))));
      }

      continue;
    }

    if (!entry.isFile()) {
      continue;
    }

    const relativePath = normalizePath(path.join(currentPath, entry.name));
    const absolutePath = path.join(rootPath, relativePath);
    const stat = await fs.stat(absolutePath);

    files.push({
      sourcePath: relativePath,
      projectPath: relativePath,
      absolutePath,
      size: stat.size,
      lastModified: stat.mtime.toISOString()
    });
  }

  return files;
}

async function listBrowserFolderFiles(source: ProjectSourceForScan): Promise<SourceFile[]> {
  const importPrefix = getStringConfig(source.source_config, "importPrefix");
  const rootName = getStringConfig(source.source_config, "rootName");

  if (!importPrefix) {
    throw new ProjectScanError("У проекта нет prefix импорта для сканирования.");
  }

  const normalizedPrefix = importPrefix.endsWith("/") ? importPrefix : `${importPrefix}/`;
  const objects = await storageProvider.listPrefix(normalizedPrefix);

  return objects
    .map((object: StorageObjectSummary) => {
      const sourcePath = object.key.slice(normalizedPrefix.length);

      return {
        sourcePath,
        projectPath: stripRootName(sourcePath, rootName),
        storageKey: object.key,
        size: object.size,
        lastModified: object.lastModified,
        etag: object.etag
      };
    })
    .filter((file) => file.sourcePath && file.sourcePath !== "manifest.json");
}

async function listSourceFiles(source: ProjectSourceForScan) {
  if (source.source_type === "browser_folder") {
    return listBrowserFolderFiles(source);
  }

  if (source.source_type === "local_folder") {
    const sourcePath = getStringConfig(source.source_config, "path");

    if (!sourcePath) {
      throw new ProjectScanError("У проекта нет локального пути для сканирования.");
    }

    return walkLocalDirectory(sourcePath);
  }

  throw new ProjectScanError("Сканирование этого типа источника пока не поддерживается.");
}

async function readHtmlFile(file: SourceFile) {
  if (file.storageKey) {
    return storageProvider.getObjectText(file.storageKey);
  }

  if (file.absolutePath) {
    return fs.readFile(file.absolutePath, "utf8");
  }

  throw new ProjectScanError(`Не удалось прочитать HTML-файл ${file.sourcePath}.`);
}

async function readSourceText(source: ProjectSourceForScan, requestedPath: string) {
  const normalizedRequestedPath = normalizePath(requestedPath);

  if (source.source_type === "local_folder") {
    const rootPath = getStringConfig(source.source_config, "path");

    if (!rootPath) {
      return null;
    }

    try {
      return await fs.readFile(path.join(rootPath, normalizedRequestedPath), "utf8");
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === "ENOENT") {
        return null;
      }

      throw error;
    }
  }

  if (source.source_type === "browser_folder") {
    const importPrefix = getStringConfig(source.source_config, "importPrefix");
    const rootName = getStringConfig(source.source_config, "rootName");

    if (!importPrefix) {
      return null;
    }

    const normalizedPrefix = importPrefix.endsWith("/") ? importPrefix : `${importPrefix}/`;
    const candidates = [
      normalizedRequestedPath,
      rootName ? `${normalizePath(rootName)}/${normalizedRequestedPath}` : null
    ].filter((candidate): candidate is string => Boolean(candidate));

    for (const candidate of candidates) {
      try {
        return await storageProvider.getObjectText(`${normalizedPrefix}${candidate}`);
      } catch {
        // Browser-folder imports can either include or strip the top folder name.
      }
    }
  }

  return null;
}

async function createRunningScan(source: ProjectSourceForScan) {
  const result = await pool.query<ScanVersionRow>(
    `
      insert into scan_versions (project_id, source_id, status, started_at)
      values ($1, $2, 'running', now())
      returning *;
    `,
    [source.project_id, source.source_id]
  );

  const scan = result.rows[0];

  if (!scan) {
    throw new Error("Не удалось создать версию скана.");
  }

  return scan;
}

async function markScanFailed(scanId: string, error: unknown) {
  const message = error instanceof Error ? error.message : "Неизвестная ошибка сканирования.";

  await pool.query(
    `
      update scan_versions
      set status = 'failed', completed_at = now(), error_message = $2
      where id = $1;
    `,
    [scanId, message]
  );
}

async function getPreviousPageFingerprint(client: PoolClient, pageId: string, scanVersionId: string) {
  const result = await client.query<{ fingerprint: string | null }>(
    `
      select fingerprint
      from page_versions
      where page_id = $1 and scan_version_id <> $2
      order by created_at desc
      limit 1;
    `,
    [pageId, scanVersionId]
  );

  return result.rows[0]?.fingerprint ?? null;
}

async function getPreviousMediaFingerprint(client: PoolClient, mediaId: string, scanVersionId: string) {
  const result = await client.query<{ fingerprint: string | null }>(
    `
      select raw->>'fingerprint' as fingerprint
      from media_versions
      where media_id = $1 and scan_version_id <> $2
      order by created_at desc
      limit 1;
    `,
    [mediaId, scanVersionId]
  );

  return result.rows[0]?.fingerprint ?? null;
}

function reconcileStatus(previousFingerprint: string | null, nextFingerprint: string) {
  if (!previousFingerprint) {
    return "new";
  }

  return previousFingerprint === nextFingerprint ? "matched" : "changed";
}

async function persistPage(client: PoolClient, projectId: string, scanVersionId: string, page: PageCandidate) {
  const pageScope = classifyScannedPageScope(page);
  const pageResult = await client.query<{ id: string }>(
    `
      insert into pages (project_id, logical_key, selected)
      values ($1, $2, $3)
      on conflict (project_id, logical_key)
      do update set logical_key = excluded.logical_key
      returning id;
    `,
    [projectId, page.logicalKey, pageScope.scope === "indexable_page"]
  );
  const pageId = pageResult.rows[0]?.id;

  if (!pageId) {
    throw new Error(`Не удалось сохранить страницу ${page.sourcePath}.`);
  }

  const previousFingerprint = await getPreviousPageFingerprint(client, pageId, scanVersionId);

  await client.query(
    `
      insert into page_versions (
        page_id,
        scan_version_id,
        source_path,
        url_path,
        title,
        description,
        fingerprint,
        reconcile_status,
        raw
      )
      values ($1, $2, $3, $4, $5, $6, $7, $8, $9);
    `,
    [
      pageId,
      scanVersionId,
      page.sourcePath,
      page.urlPath,
      page.title,
      page.description,
      page.fingerprint,
      reconcileStatus(previousFingerprint, page.fingerprint),
      page.raw
    ]
  );
}

async function persistAsset(client: PoolClient, projectId: string, scanVersionId: string, asset: AssetCandidate) {
  const assetResult = await client.query<{ id: string }>(
    `
      insert into media_assets (project_id, logical_key)
      values ($1, $2)
      on conflict (project_id, logical_key)
      do update set logical_key = excluded.logical_key
      returning id;
    `,
    [projectId, asset.logicalKey]
  );
  const mediaId = assetResult.rows[0]?.id;

  if (!mediaId) {
    throw new Error(`Не удалось сохранить ассет ${asset.sourcePath}.`);
  }

  const previousFingerprint = await getPreviousMediaFingerprint(client, mediaId, scanVersionId);

  await client.query(
    `
      insert into media_versions (
        media_id,
        scan_version_id,
        source_path,
        media_type,
        reconcile_status,
        raw
      )
      values ($1, $2, $3, $4, $5, $6);
    `,
    [
      mediaId,
      scanVersionId,
      asset.sourcePath,
      asset.mediaType,
      reconcileStatus(previousFingerprint, asset.fingerprint),
      asset.raw
    ]
  );
}

async function persistScanResults(
  scan: ScanVersionRow,
  pages: PageCandidate[],
  assets: AssetCandidate[],
  projectIndexKey: string
) {
  const client = await pool.connect();

  try {
    await client.query("begin");

    for (const page of pages) {
      await persistPage(client, scan.project_id, scan.id, page);
    }

    for (const asset of assets) {
      await persistAsset(client, scan.project_id, scan.id, asset);
    }

    await client.query(
      `
        update scan_versions
        set status = 'done', project_index_key = $2, completed_at = now(), error_message = null
        where id = $1;
      `,
      [scan.id, projectIndexKey]
    );

    await client.query("commit");
  } catch (error) {
    await client.query("rollback");
    throw error;
  } finally {
    client.release();
  }
}

function normalizeHeadings(raw: Record<string, unknown>) {
  const headings = raw.headings;

  if (!Array.isArray(headings)) {
    return [];
  }

  return headings
    .map((heading) => {
      if (typeof heading !== "object" || heading === null) {
        return null;
      }

      const level = "level" in heading && typeof heading.level === "number" ? heading.level : null;
      const text = "text" in heading && typeof heading.text === "string" ? heading.text : null;

      return level && text ? { level, text } : null;
    })
    .filter((heading): heading is { level: number; text: string } => Boolean(heading));
}

function getRawNumber(raw: Record<string, unknown>, key: string) {
  const value = raw[key];
  return typeof value === "number" ? value : null;
}

function normalizeMediaRefs(raw: Record<string, unknown>) {
  const mediaRefs = raw.mediaRefs;

  if (!Array.isArray(mediaRefs)) {
    return [];
  }

  return mediaRefs
    .map((mediaRef): HtmlMediaRef | null => {
      if (typeof mediaRef !== "object" || mediaRef === null || !("tag" in mediaRef)) {
        return null;
      }

      const tag = mediaRef.tag === "img" || mediaRef.tag === "video" ? mediaRef.tag : null;

      if (!tag) {
        return null;
      }

      const normalizedMediaRef: HtmlMediaRef = {
        tag,
        source: "source" in mediaRef && typeof mediaRef.source === "string" ? mediaRef.source : null,
        alt: "alt" in mediaRef && typeof mediaRef.alt === "string" ? mediaRef.alt : null,
        title: "title" in mediaRef && typeof mediaRef.title === "string" ? mediaRef.title : null,
        ariaLabel: "ariaLabel" in mediaRef && typeof mediaRef.ariaLabel === "string" ? mediaRef.ariaLabel : null,
        poster: "poster" in mediaRef && typeof mediaRef.poster === "string" ? mediaRef.poster : null
      };

      return normalizedMediaRef;
    })
    .filter((mediaRef): mediaRef is HtmlMediaRef => mediaRef !== null);
}

function createAuditIssue(input: Omit<BaselineAuditIssue, "id">): BaselineAuditIssue {
  return {
    ...input,
    id: hashValue(
      [
        input.severity,
        input.scope,
        input.code,
        input.pageId ?? "",
        input.mediaId ?? "",
        input.sourcePath ?? "",
        input.field ?? "",
        input.message
      ].join("|")
    ).slice(0, 16)
  };
}

function pushIssue(issues: BaselineAuditIssue[], issue: Omit<BaselineAuditIssue, "id">) {
  issues.push(createAuditIssue(issue));
}

function getHeadingLevelCounts(headings: Array<{ level: number; text: string }>) {
  return headings.reduce<Record<number, number>>((counts, heading) => {
    counts[heading.level] = (counts[heading.level] ?? 0) + 1;
    return counts;
  }, {});
}

function getWeakFilenameReason(sourcePath: string) {
  const filename = path.basename(sourcePath, path.extname(sourcePath)).toLocaleLowerCase();

  if (/^(image|img|photo|picture|screenshot|frame|untitled|default)[-_]?\d*$/i.test(filename)) {
    return "Файл называется слишком общо, это слабый сигнал для media SEO.";
  }

  if (/^\d+$/.test(filename)) {
    return "Имя файла состоит только из цифр, лучше дать осмысленное имя.";
  }

  return null;
}

function dedupeAuditIssues(issues: BaselineAuditIssue[]) {
  return Array.from(new Map(issues.map((issue) => [issue.id, issue])).values());
}

function buildBaselineAuditOutput(
  scan: ScanVersionRow,
  pages: PageVersionRow[],
  assets: MediaVersionRow[]
): BaselineAuditOutput {
  const issues: BaselineAuditIssue[] = [];
  const descriptionPages = new Map<string, PageVersionRow[]>();
  const titlePages = new Map<string, PageVersionRow[]>();
  let missingTitle = 0;
  let missingDescription = 0;
  let missingH1 = 0;

  for (const page of pages) {
    const headings = normalizeHeadings(page.raw);
    const headingCounts = getHeadingLevelCounts(headings);
    const bodyTextLength = getRawNumber(page.raw, "bodyTextLength") ?? 0;

    if (!page.title) {
      missingTitle += 1;
      pushIssue(issues, {
        severity: "warning",
        scope: "page",
        code: "missing_title",
        message: "На странице не найден title.",
        pageId: page.page_id,
        sourcePath: page.source_path,
        urlPath: page.url_path,
        field: "title"
      });
    } else {
      const normalizedTitle = page.title.trim().toLocaleLowerCase();
      titlePages.set(normalizedTitle, [...(titlePages.get(normalizedTitle) ?? []), page]);

      if (page.title.length > 70) {
        pushIssue(issues, {
          severity: "warning",
          scope: "page",
          code: "long_title",
          message: `Title может быть слишком длинным для выдачи (${page.title.length} символов).`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "title"
        });
      } else if (page.title.length < 20) {
        pushIssue(issues, {
          severity: "info",
          scope: "page",
          code: "short_title",
          message: `Title короткий (${page.title.length} символов), стоит проверить смысловую полноту.`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "title"
        });
      }
    }

    if (!page.description) {
      missingDescription += 1;
      pushIssue(issues, {
        severity: "warning",
        scope: "page",
        code: "missing_description",
        message: "На странице не найден meta description.",
        pageId: page.page_id,
        sourcePath: page.source_path,
        urlPath: page.url_path,
        field: "description"
      });
    } else {
      const normalizedDescription = page.description.trim().toLocaleLowerCase();
      descriptionPages.set(normalizedDescription, [...(descriptionPages.get(normalizedDescription) ?? []), page]);

      if (page.description.length > 170) {
        pushIssue(issues, {
          severity: "warning",
          scope: "page",
          code: "long_description",
          message: `Description может быть слишком длинным для сниппета (${page.description.length} символов).`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "description"
        });
      } else if (page.description.length < 70) {
        pushIssue(issues, {
          severity: "info",
          scope: "page",
          code: "short_description",
          message: `Description короткий (${page.description.length} символов), стоит проверить информативность.`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "description"
        });
      }
    }

    if (!headingCounts[1]) {
      missingH1 += 1;
      pushIssue(issues, {
        severity: "warning",
        scope: "page",
        code: "missing_h1",
        message: "На странице не найден H1.",
        pageId: page.page_id,
        sourcePath: page.source_path,
        urlPath: page.url_path,
        field: "h1"
      });
    } else if (headingCounts[1] > 1) {
      pushIssue(issues, {
        severity: "warning",
        scope: "page",
        code: "multiple_h1",
        message: `На странице найдено несколько H1: ${headingCounts[1]}.`,
        pageId: page.page_id,
        sourcePath: page.source_path,
        urlPath: page.url_path,
        field: "h1"
      });
    }

    for (let index = 1; index < headings.length; index += 1) {
      const previous = headings[index - 1];
      const current = headings[index];

      if (previous && current && current.level - previous.level > 1) {
        pushIssue(issues, {
          severity: "info",
          scope: "page",
          code: "heading_level_jump",
          message: `Заголовок H${current.level} идёт после H${previous.level}, порядок стоит проверить.`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "headings"
        });
        break;
      }
    }

    if (bodyTextLength > 0 && bodyTextLength < 300) {
      pushIssue(issues, {
        severity: "info",
        scope: "page",
        code: "thin_visible_text",
        message: `Видимого текста мало: примерно ${bodyTextLength} символов.`,
        pageId: page.page_id,
        sourcePath: page.source_path,
        urlPath: page.url_path,
        field: "content"
      });
    }

    const imageAltValues = new Map<string, number>();

    for (const mediaRef of normalizeMediaRefs(page.raw)) {
      if (mediaRef.tag === "img") {
        const alt = mediaRef.alt?.trim() ?? "";

        if (!mediaRef.alt || alt.length === 0) {
          pushIssue(issues, {
            severity: "warning",
            scope: "media",
            code: "missing_alt",
            message: `У изображения ${mediaRef.source ?? "без src"} нет alt.`,
            pageId: page.page_id,
            sourcePath: page.source_path,
            urlPath: page.url_path,
            field: "alt"
          });
        } else if (alt.length < 5) {
          pushIssue(issues, {
            severity: "info",
            scope: "media",
            code: "weak_alt",
            message: `Alt слишком короткий: «${alt}».`,
            pageId: page.page_id,
            sourcePath: page.source_path,
            urlPath: page.url_path,
            field: "alt"
          });
        }

        if (alt) {
          const normalizedAlt = alt.toLocaleLowerCase();
          imageAltValues.set(normalizedAlt, (imageAltValues.get(normalizedAlt) ?? 0) + 1);
        }
      }

      if (mediaRef.tag === "video" && !mediaRef.poster) {
        pushIssue(issues, {
          severity: "info",
          scope: "media",
          code: "video_without_poster",
          message: `У видео ${mediaRef.source ?? "без src"} не найден poster.`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "poster"
        });
      }
    }

    for (const [alt, count] of imageAltValues.entries()) {
      if (count > 1) {
        pushIssue(issues, {
          severity: "warning",
          scope: "media",
          code: "duplicate_alt",
          message: `Alt повторяется ${count} раза: «${alt}».`,
          pageId: page.page_id,
          sourcePath: page.source_path,
          urlPath: page.url_path,
          field: "alt"
        });
      }
    }
  }

  for (const duplicatePages of titlePages.values()) {
    if (duplicatePages.length > 1) {
      pushIssue(issues, {
        severity: "warning",
        scope: "project",
        code: "duplicate_title",
        message: `Title повторяется на ${duplicatePages.length} страницах: «${duplicatePages[0]?.title ?? ""}».`,
        sourcePath: duplicatePages.slice(0, 3).map((page) => page.source_path).join(", "),
        field: "title"
      });
    }
  }

  for (const duplicatePages of descriptionPages.values()) {
    if (duplicatePages.length > 1) {
      pushIssue(issues, {
        severity: "warning",
        scope: "project",
        code: "duplicate_description",
        message: `Meta description повторяется на ${duplicatePages.length} страницах.`,
        sourcePath: duplicatePages.slice(0, 3).map((page) => page.source_path).join(", "),
        field: "description"
      });
    }
  }

  for (const asset of assets) {
    if (!["image", "video"].includes(asset.media_type)) {
      continue;
    }

    const weakFilenameReason = getWeakFilenameReason(asset.source_path);

    if (weakFilenameReason) {
      pushIssue(issues, {
        severity: "info",
        scope: "media",
        code: "weak_filename",
        message: weakFilenameReason,
        mediaId: asset.media_id,
        sourcePath: asset.source_path,
        field: "filename"
      });
    }
  }

  const dedupedIssues = dedupeAuditIssues(issues);
  const mediaWarnings = dedupedIssues.filter((issue) => issue.scope === "media" && issue.severity === "warning").length;
  const counts = dedupedIssues.reduce(
    (accumulator, issue) => {
      accumulator.total += 1;
      if (issue.severity === "error") accumulator.errors += 1;
      if (issue.severity === "warning") accumulator.warnings += 1;
      if (issue.severity === "info") accumulator.info += 1;
      return accumulator;
    },
    { errors: 0, info: 0, total: 0, warnings: 0 }
  );

  return {
    scanVersionId: scan.id,
    summary: {
      pagesChecked: pages.length,
      assetsChecked: assets.length,
      missingTitle,
      missingDescription,
      missingH1,
      mediaWarnings
    },
    counts,
    issues: dedupedIssues
  };
}

async function getScanRowsForAudit(scanVersionId: string) {
  const scanResult = await pool.query<ScanVersionRow>("select * from scan_versions where id = $1", [scanVersionId]);
  const scan = scanResult.rows[0];

  if (!scan) {
    return null;
  }

  const pageResult = await pool.query<PageVersionRow>(
    `
      select
        pv.page_id,
        p.selected,
        pv.source_path,
        pv.url_path,
        pv.title,
        pv.description,
        pv.fingerprint,
        pv.reconcile_status,
        pv.raw
      from page_versions pv
      join pages p on p.id = pv.page_id
      where pv.scan_version_id = $1
      order by coalesce(pv.url_path, pv.source_path) asc;
    `,
    [scanVersionId]
  );
  const mediaResult = await pool.query<MediaVersionRow>(
    `
      select
        mv.media_id,
        mv.source_path,
        mv.media_type,
        mv.reconcile_status,
        mv.raw
      from media_versions mv
      where mv.scan_version_id = $1
      order by mv.media_type asc, mv.source_path asc;
    `,
    [scanVersionId]
  );

  return {
    scan,
    pages: pageResult.rows,
    assets: mediaResult.rows
  };
}

function mapBaselineAuditRun(row: BaselineAuditRunRow): BaselineAuditSummary {
  const output = row.output ?? {
    scanVersionId: "",
    summary: {
      pagesChecked: 0,
      assetsChecked: 0,
      missingTitle: 0,
      missingDescription: 0,
      missingH1: 0,
      mediaWarnings: 0
    },
    counts: {
      total: 0,
      errors: 0,
      warnings: 0,
      info: 0
    },
    issues: []
  };

  return {
    runId: row.id,
    status: row.status,
    startedAt: toIsoDate(row.started_at),
    completedAt: toIsoDate(row.completed_at),
    errorMessage: row.error_message,
    createdAt: row.created_at.toISOString(),
    ...output
  };
}

async function getBaselineAuditByScanId(scanVersionId: string) {
  const result = await pool.query<BaselineAuditRunRow>(
    `
      select id, status, output, error_message, started_at, completed_at, created_at
      from runs
      where run_type = 'baseline_audit' and input->>'scanVersionId' = $1
      order by created_at desc
      limit 1;
    `,
    [scanVersionId]
  );
  const row = result.rows[0];

  return row ? mapBaselineAuditRun(row) : null;
}

async function runBaselineAudit(scanVersionId: string) {
  const scanRows = await getScanRowsForAudit(scanVersionId);

  if (!scanRows) {
    throw new ProjectScanError("Версия скана для аудита не найдена.");
  }

  const output = buildBaselineAuditOutput(scanRows.scan, scanRows.pages, scanRows.assets);
  const result = await pool.query<BaselineAuditRunRow>(
    `
      insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
      values ($1, 'baseline_audit', 'done', $2, $3, now(), now())
      returning id, status, output, error_message, started_at, completed_at, created_at;
    `,
    [
      scanRows.scan.project_id,
      {
        scanVersionId
      },
      output
    ]
  );
  const row = result.rows[0];

  if (!row) {
    throw new Error("Не удалось сохранить результат базового аудита.");
  }

  return mapBaselineAuditRun(row);
}

async function getScanSummaryById(scanVersionId: string): Promise<ProjectScanSummary | null> {
  const scanResult = await pool.query<ScanVersionRow>("select * from scan_versions where id = $1", [scanVersionId]);
  const scan = scanResult.rows[0];

  if (!scan) {
    return null;
  }

  const pageResult = await pool.query<PageVersionRow>(
    `
      select
        pv.page_id,
        p.selected,
        pv.source_path,
        pv.url_path,
        pv.title,
        pv.description,
        pv.fingerprint,
        pv.reconcile_status,
        pv.raw
      from page_versions pv
      join pages p on p.id = pv.page_id
      where pv.scan_version_id = $1
      order by coalesce(pv.url_path, pv.source_path) asc;
    `,
    [scanVersionId]
  );
  const mediaResult = await pool.query<MediaVersionRow>(
    `
      select
        mv.media_id,
        mv.source_path,
        mv.media_type,
        mv.reconcile_status,
        mv.raw
      from media_versions mv
      where mv.scan_version_id = $1
      order by mv.media_type asc, mv.source_path asc;
    `,
    [scanVersionId]
  );
  const source = await getProjectSourceById(scan.source_id);
  const homeStructure = source ? parseHomeStructure(await readSourceText(source, "content/pages/home.json")) : null;

  const pages = pageResult.rows.map((row) => {
    const pageScope = classifyScannedPageScope(row);
    const previewMeta = getPagePreviewMeta(row, pageScope, homeStructure);

    return {
      pageId: row.page_id,
      selected: row.selected,
      sourcePath: row.source_path,
      urlPath: row.url_path,
      title: row.title,
      description: row.description,
      fingerprint: row.fingerprint,
      reconcileStatus: row.reconcile_status,
      scope: pageScope.scope,
      scopeReason: pageScope.reason,
      ...previewMeta,
      usedForCoverage: row.selected && pageScope.scope === "indexable_page",
      headings: normalizeHeadings(row.raw)
    };
  });
  const siteSections = buildSiteSections(homeStructure, pages);
  const assets = mediaResult.rows.map((row) => ({
    mediaId: row.media_id,
    sourcePath: row.source_path,
    mediaType: row.media_type,
    size: getRawNumber(row.raw, "size"),
    reconcileStatus: row.reconcile_status
  }));
  const audit = await getBaselineAuditByScanId(scanVersionId);

  return {
    id: scan.id,
    projectId: scan.project_id,
    sourceId: scan.source_id,
    status: scan.status,
    projectIndexKey: scan.project_index_key,
    startedAt: toIsoDate(scan.started_at),
    completedAt: toIsoDate(scan.completed_at),
    errorMessage: scan.error_message,
    createdAt: scan.created_at.toISOString(),
    counts: {
      pages: pages.length,
      assets: assets.length
    },
    audit,
    pages,
    siteSections,
    assets
  };
}

export async function getLatestProjectScan(projectId: string) {
  const result = await pool.query<{ id: string }>(
    `
      select id
      from scan_versions
      where project_id = $1
      order by created_at desc
      limit 1;
    `,
    [projectId]
  );
  const scanId = result.rows[0]?.id;

  return scanId ? getScanSummaryById(scanId) : null;
}

export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) {
  const client = await pool.connect();

  try {
    await client.query("begin");

    const result = await client.query(
      `
        update pages
        set selected = $3
        where project_id = $1 and id = any($2::uuid[]);
      `,
      [projectId, pageIds, selected]
    );

    await client.query("commit");

    return {
      updatedCount: result.rowCount ?? 0,
      scan: await getLatestProjectScan(projectId)
    };
  } catch (error) {
    await client.query("rollback");
    throw error;
  } finally {
    client.release();
  }
}

export async function scanProject(projectId: string) {
  const source = await getProjectSource(projectId);

  if (!source) {
    return null;
  }

  const scan = await createRunningScan(source);

  try {
    const files = await listSourceFiles(source);
    const pages: PageCandidate[] = [];
    const assets: AssetCandidate[] = [];

    for (const file of files) {
      if (HTML_EXTENSIONS.has(getExtension(file.sourcePath))) {
        pages.push(parseHtmlPage(file, await readHtmlFile(file)));
        continue;
      }

      const mediaType = getAssetType(file.sourcePath);

      if (mediaType) {
        assets.push(buildAssetCandidate(file, mediaType));
      }
    }

    const projectIndexKey = await saveProjectIndexArtifact(scan, source, files, pages, assets);

    await persistScanResults(scan, pages, assets, projectIndexKey);
    await runBaselineAudit(scan.id);

    return getScanSummaryById(scan.id);
  } catch (error) {
    await markScanFailed(scan.id, error);
    throw error;
  }
}
