import fs from "node:fs/promises";
import path from "node:path";
import { pool } from "../db/client.js";
import { storageProvider } from "../storage/index.js";

type LatestScanRow = {
  id: string;
  project_id: string;
  source_id: string;
  project_index_key: string | null;
  completed_at: Date | null;
  created_at: Date;
  source_type: string;
  source_config: Record<string, unknown>;
};

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

type DataAssetRow = {
  source_path: string;
};

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

type ContentDocument = {
  id: string;
  kind: "page" | "content_json";
  selected: boolean;
  scope: PageScope | "content_source";
  scopeReason: string;
  usedForCoverage: boolean;
  title: string;
  sourcePath: string;
  urlPath: string | null;
  text: string;
  wordCount: number;
};

type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";

type AnalysisSignal = {
  id: string;
  level: "ok" | "warning" | "problem";
  title: string;
  message: string;
};

type AnalysisVerdict = {
  confidence: "low" | "medium" | "high";
  marketObjectivity: "internal_only" | "partial" | "market_ready";
  summary: string;
  whatWorks: string[];
  limits: string[];
  externalSignalsNeeded: string[];
  risks: Array<{
    id: string;
    level: "warning" | "problem";
    title: string;
    message: string;
  }>;
  clusterAssessments: Array<{
    clusterId: string;
    title: string;
    assessment: "confirmed_in_content" | "weak_in_content" | "missing_in_content" | "needs_market_validation";
    needsMarketValidation: boolean;
    reason: string;
  }>;
  nextValidationStep: string;
};

type IntentSignalGroupId =
  | "agents"
  | "ai"
  | "automation"
  | "business"
  | "commercial"
  | "data"
  | "development"
  | "engineering"
  | "enterprise"
  | "implementation"
  | "integrations"
  | "operations"
  | "process"
  | "security";

type SemanticClusterDefinition = {
  id: string;
  title: string;
  priority: "high" | "medium" | "low";
  targetIntent: string;
  intentGroups: IntentSignalGroupId[];
  keywords: string[];
  queryExamples: string[];
  targetPages: string[];
};

type LandingOpportunityPageType =
  | "conversion"
  | "deployment"
  | "docs"
  | "home"
  | "integration"
  | "landing"
  | "module"
  | "security"
  | "use_case";

type LegacyMarker = {
  id: string;
  label: string;
  terms: string[];
  severity: "blocker" | "warning";
  action: string;
};

type BriefQualityContract = {
  version: "brief-quality.v1";
  score: number;
  level: "ready" | "needs_work" | "blocked";
  summary: string;
  nextStep: string;
  metrics: {
    h1Length: number;
    titleLength: number;
    metaDescriptionLength: number;
    clusterCount: number;
    sectionCount: number;
    seedPhraseCount: number;
    mustUseTermCount: number;
    internalLinkCount: number;
    checklistDone: number;
    checklistPending: number;
  };
  checks: Array<{
    id: string;
    title: string;
    status: "pass" | "warn" | "fail";
    reason: string;
  }>;
  riskFlags: Array<{
    id: string;
    level: "warning" | "problem";
    title: string;
    message: string;
  }>;
  missingEvidence: string[];
};

export type SemanticAnalysisOutput = {
  schemaVersion: "semantic-analysis.v1";
  projectId: string;
  scanVersionId: string;
  sourceId: string;
  projectIndexKey: string | null;
  generatedAt: string;
  summary: {
    coverageScore: number;
    lexicalCoverageScore: number;
    evidenceScore: number;
    intentCoverageScore: number;
    commercialReadinessScore: number;
    coveredClusters: number;
    weakClusters: number;
    missingClusters: number;
    legacyFindings: number;
    pagesAnalyzed: number;
    selectedPagesAnalyzed: number;
    contentSourcesAnalyzed: number;
    indexablePagesAnalyzed: number;
    excludedHtmlDocuments: number;
    qualityProblems: number;
    qualityWarnings: number;
    seedCandidates: number;
  };
  pageScope: {
    indexablePages: number;
    selectedIndexablePages: number;
    unselectedIndexablePages: number;
    templateBlocks: number;
    adminPages: number;
    technicalPages: number;
    contentSources: number;
    coverageDocuments: number;
    excludedDocuments: number;
  };
  styleProfile: {
    language: "ru" | "mixed" | "unknown";
    averageSentenceWords: number;
    topTerms: Array<{
      term: string;
      count: number;
    }>;
    brandTerms: string[];
    ctaPhrases: string[];
    toneSignals: string[];
  };
  qualitySignals: AnalysisSignal[];
  analysisVerdict: AnalysisVerdict;
  clusters: Array<{
    id: string;
    title: string;
    priority: SemanticClusterDefinition["priority"];
    targetIntent: string;
    status: "covered" | "weak" | "missing";
    score: number;
    hits: number;
    matchedTerms: string[];
    missingTerms: string[];
    queryExamples: string[];
    targetPages: string[];
    evidenceSnippets: Array<{
      sourceId: string;
      sourceKind: ContentDocument["kind"];
      sourcePath: string;
      title: string;
      text: string;
      matchedTerms: string[];
    }>;
    sourceRefs: Array<{
      id: string;
      kind: ContentDocument["kind"];
      scope: ContentDocument["scope"];
      title: string;
      sourcePath: string;
      urlPath: string | null;
      hits: number;
    }>;
    evidence: {
      level: "none" | "thin" | "moderate" | "strong";
      score: number;
      lexicalScore: number;
      intentScore: number;
      landingPageHits: number;
      contentSourceHits: number;
      uniqueSources: number;
      matchedIntentGroups: string[];
      missingIntentGroups: string[];
      supportSignals: string[];
      penalties: string[];
    };
  }>;
  pages: Array<{
    pageId: string;
    selected: boolean;
    title: string;
    sourcePath: string;
    urlPath: string | null;
    scope: PageScope;
    scopeReason: string;
    usedForCoverage: boolean;
    wordCount: number;
    matchedClusters: string[];
    missingCriticalFields: string[];
  }>;
  contentSources: Array<{
    id: string;
    sourcePath: string;
    title: string;
    usedForCoverage: boolean;
    wordCount: number;
    matchedClusters: string[];
  }>;
  legacyFindings: Array<{
    markerId: string;
    label: string;
    severity: LegacyMarker["severity"];
    sourceId: string;
    sourceKind: ContentDocument["kind"];
    title: string;
    sourcePath: string;
    urlPath: string | null;
    count: number;
    action: string;
  }>;
  gaps: Array<{
    clusterId: string;
    title: string;
    status: "weak" | "missing";
    priority: SemanticClusterDefinition["priority"];
    targetIntent: string;
    targetPages: string[];
    seedExamples: string[];
  }>;
  landingOpportunities: Array<{
    clusterId: string;
    title: string;
    priority: SemanticClusterDefinition["priority"];
    status: "covered" | "weak" | "missing";
    score: number;
    evidenceLevel: "none" | "thin" | "moderate" | "strong";
    targetIntent: string;
    pageType: LandingOpportunityPageType;
    action: "create_target_page" | "keep_home" | "strengthen_existing" | "validate_external";
    currentLandingPages: string[];
    targetPages: string[];
    existingTargetPages: string[];
    missingTargetPages: string[];
    recommendedPages: Array<{
      path: string;
      pageType: LandingOpportunityPageType;
      action: "create" | "strengthen" | "validate";
      reason: string;
    }>;
    reason: string;
  }>;
  landingPagePlan: Array<{
    path: string;
    pageType: LandingOpportunityPageType;
    action: "create" | "strengthen" | "validate";
    priority: SemanticClusterDefinition["priority"];
    readinessScore: number;
    opportunityScore: number;
    coveredClusterCount: number;
    weakClusterCount: number;
    missingClusterCount: number;
    reason: string;
    currentSources: string[];
    seedPhrases: string[];
    clusters: Array<{
      clusterId: string;
      title: string;
      priority: SemanticClusterDefinition["priority"];
      status: "covered" | "weak" | "missing";
      score: number;
      evidenceLevel: "none" | "thin" | "moderate" | "strong";
      action: "create_target_page" | "keep_home" | "strengthen_existing" | "validate_external";
      targetIntent: string;
    }>;
  }>;
  landingPageBriefs: Array<{
    path: string;
    pageType: LandingOpportunityPageType;
    action: "create" | "strengthen" | "validate";
    priority: SemanticClusterDefinition["priority"];
    h1: string;
    title: string;
    metaDescription: string;
    targetIntent: string;
    audience: string;
    primaryCta: string;
    secondaryCta: string;
    introAngle: string;
    mustUseTerms: string[];
    seedPhrases: string[];
    internalLinks: string[];
    sections: Array<{
      title: string;
      goal: string;
      mustCoverTerms: string[];
    }>;
    readinessChecklist: Array<{
      id: string;
      title: string;
      done: boolean;
      reason: string;
    }>;
    clusters: Array<{
      clusterId: string;
      title: string;
      status: "covered" | "weak" | "missing";
      priority: SemanticClusterDefinition["priority"];
      evidenceLevel: "none" | "thin" | "moderate" | "strong";
    }>;
    qualityContract: BriefQualityContract;
  }>;
  seedCandidates: Array<{
    phrase: string;
    clusterId: string;
    clusterTitle: string;
    source: "ontology" | "detected";
    reason: string;
    priority: SemanticClusterDefinition["priority"];
  }>;
  nextActions: string[];
};

export type SemanticAnalysisRun = SemanticAnalysisOutput & {
  runId: string;
  status: string;
  startedAt: string | null;
  completedAt: string | null;
  errorMessage: string | null;
  createdAt: string;
};

export type SemanticAnalysisExportFormat = "csv" | "json" | "markdown";

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

const NODEDC_CLUSTERS: SemanticClusterDefinition[] = [
  {
    id: "ai_platform_development",
    title: "AI-платформа для разработки и автоматизации",
    priority: "high",
    targetIntent: "Базовый коммерческий интент: платформа, разработка, автоматизация, искусственный интеллект.",
    intentGroups: ["ai", "automation", "business", "development", "enterprise"],
    keywords: [
      "платформа",
      "разработка",
      "искусственный интеллект",
      "ai",
      "llm",
      "low-code",
      "no-code",
      "автоматизация",
      "продуктовая разработка"
    ],
    queryExamples: [
      "платформа автоматизации бизнеса ai",
      "ai платформа для разработки",
      "разработка бизнес приложений с искусственным интеллектом"
    ],
    targetPages: ["/", "/platform/", "/modules/"]
  },
  {
    id: "business_process_automation",
    title: "Автоматизация бизнес-процессов",
    priority: "high",
    targetIntent: "BPM/workflow рынок: процессы, регламенты, контроль исполнения, сквозные цепочки.",
    intentGroups: ["automation", "business", "process", "operations", "implementation"],
    keywords: [
      "бизнес-процесс",
      "workflow",
      "bpm",
      "регламент",
      "процесс",
      "операционный контур",
      "сквозной процесс",
      "маршрут согласования"
    ],
    queryExamples: [
      "автоматизация бизнес процессов",
      "workflow система для бизнеса",
      "bpm платформа с искусственным интеллектом"
    ],
    targetPages: ["/use-cases/", "/modules/ops/", "/modules/engine/"]
  },
  {
    id: "agentic_automation",
    title: "Агентная автоматизация и AI-ассистенты",
    priority: "high",
    targetIntent: "Трендовый интент: AI agents, ассистенты, автономные проверки и выполнение задач.",
    intentGroups: ["agents", "ai", "automation", "operations"],
    keywords: [
      "агент",
      "агентная",
      "ai agent",
      "ассистент",
      "codex",
      "нейросеть",
      "модель",
      "автономный",
      "toolpack"
    ],
    queryExamples: [
      "ai агенты для бизнеса",
      "агентная автоматизация процессов",
      "ai ассистент для разработки и операций"
    ],
    targetPages: ["/modules/assistant/", "/modules/ops/", "/docs/"]
  },
  {
    id: "ops_project_control",
    title: "Ops, Tasker и управление проектами",
    priority: "medium",
    targetIntent: "Операционное управление: задачи, чекеры, статусы, проектные карточки.",
    intentGroups: ["operations", "business", "process", "implementation"],
    keywords: [
      "ops",
      "tasker",
      "задача",
      "проект",
      "проверка",
      "чекер",
      "статус",
      "исполнитель",
      "операционный"
    ],
    queryExamples: [
      "ai ops управление задачами",
      "система контроля проектов с ai",
      "автоматизация проектного офиса"
    ],
    targetPages: ["/modules/ops/", "/use-cases/project-office/"]
  },
  {
    id: "hub_workspace_rbac",
    title: "Hub, workspace, роли и доступы",
    priority: "medium",
    targetIntent: "Рабочие пространства, запуск модулей, роли, витрина приложений.",
    intentGroups: ["business", "enterprise", "operations", "security"],
    keywords: [
      "hub",
      "workspace",
      "рабочее пространство",
      "роль",
      "rbac",
      "доступ",
      "витрина",
      "launcher",
      "команда"
    ],
    queryExamples: [
      "ai workspace для команды",
      "hub платформа бизнес приложений",
      "rbac для ai платформы"
    ],
    targetPages: ["/modules/hub/", "/security/"]
  },
  {
    id: "engine_infrastructure",
    title: "Engine, инфраструктура и оркестрация",
    priority: "medium",
    targetIntent: "Технический слой: engine, docker, deploy, сервисы, mcp, контуры.",
    intentGroups: ["development", "implementation", "enterprise", "integrations"],
    keywords: [
      "engine",
      "оркестрация",
      "инфраструктура",
      "docker",
      "deploy",
      "mcp",
      "сервис",
      "pipeline",
      "контур"
    ],
    queryExamples: [
      "оркестрация ai сервисов",
      "инфраструктура для ai автоматизации",
      "mcp интеграции для бизнеса"
    ],
    targetPages: ["/modules/engine/", "/deployment/", "/docs/"]
  },
  {
    id: "integrations_api",
    title: "Интеграции, API, 1C, CRM/ERP",
    priority: "high",
    targetIntent: "Коммерческий интент интеграций: 1С, CRM, ERP, API, webhooks.",
    intentGroups: ["integrations", "business", "automation", "implementation"],
    keywords: [
      "интеграция",
      "api",
      "webhook",
      "1с",
      "1c",
      "crm",
      "erp",
      "битрикс",
      "telegram",
      "внешняя система"
    ],
    queryExamples: [
      "ai интеграция с 1с",
      "автоматизация 1с с искусственным интеллектом",
      "api платформа для интеграции бизнес систем"
    ],
    targetPages: ["/integrations/", "/modules/1c-assistant/"]
  },
  {
    id: "tender_legal_procurement",
    title: "Тендеры, закупки и юридические процессы",
    priority: "medium",
    targetIntent: "Отраслевые сценарии с высоким value: тендеры, договоры, закупки, юристы.",
    intentGroups: ["business", "process", "automation", "commercial"],
    keywords: [
      "тендер",
      "закупка",
      "договор",
      "юридический",
      "юрист",
      "согласование",
      "коммерческое предложение",
      "поставщик"
    ],
    queryExamples: [
      "ai для тендеров",
      "автоматизация закупок с ai",
      "ai ассистент для договоров"
    ],
    targetPages: ["/modules/tender/", "/use-cases/procurement/", "/use-cases/legal/"]
  },
  {
    id: "digital_twin_3d_data",
    title: "Digital Twin, BIM, 3D и инженерные данные",
    priority: "medium",
    targetIntent: "Инженерный рынок: цифровые двойники, BIM, STEP/STL/3DGS/LAS, облака точек.",
    intentGroups: ["engineering", "data", "ai", "enterprise"],
    keywords: [
      "digital twin",
      "цифровой двойник",
      "bim",
      "step",
      "stl",
      "3dgs",
      "las",
      "облако точек",
      "инженерный",
      "модель объекта"
    ],
    queryExamples: [
      "ai digital twin платформа",
      "цифровой двойник bim ai",
      "автоматизация инженерных данных"
    ],
    targetPages: ["/modules/digital-twin/", "/use-cases/construction/"]
  },
  {
    id: "private_cloud_security",
    title: "Private cloud, on-prem и безопасность",
    priority: "high",
    targetIntent: "B2B enterprise интент: данные, контур, безопасность, развертывание.",
    intentGroups: ["enterprise", "security", "data", "implementation"],
    keywords: [
      "on-prem",
      "private cloud",
      "частное облако",
      "безопасность",
      "данные",
      "контур",
      "сервер",
      "инфраструктура заказчика",
      "изоляция"
    ],
    queryExamples: [
      "ai платформа on premise",
      "частное облако для ai автоматизации",
      "безопасная ai платформа для бизнеса"
    ],
    targetPages: ["/deployment/", "/security/"]
  },
  {
    id: "conversion_pilot_pricing",
    title: "Демо, пилот, тарифы и внедрение",
    priority: "high",
    targetIntent: "Нижняя часть воронки: запрос демо, пилот, стоимость, внедрение.",
    intentGroups: ["commercial", "business", "implementation"],
    keywords: [
      "демо",
      "пилот",
      "тариф",
      "стоимость",
      "внедрение",
      "проект",
      "заявка",
      "консультация",
      "коммерческое предложение"
    ],
    queryExamples: [
      "заказать ai автоматизацию бизнеса",
      "стоимость внедрения ai платформы",
      "пилот ai автоматизации бизнес процессов"
    ],
    targetPages: ["/tariffs/", "/pilot/", "/contacts/"]
  }
];

const LEGACY_MARKERS: LegacyMarker[] = [
  {
    id: "webflow",
    label: "Webflow",
    terms: ["webflow", "webflow.io", "webflow.js"],
    severity: "blocker",
    action: "Удалить упоминание, ссылку или технический след Webflow из исходника/контента."
  },
  {
    id: "ss_script",
    label: "SS-script",
    terms: ["ss-script", "ss script", "ss_script", "ssscript"],
    severity: "blocker",
    action: "Заменить старое продуктово-техническое упоминание на NodeDC или удалить."
  },
  {
    id: "federat",
    label: "federat.oo",
    terms: ["federat.oo", "federat", "federation old"],
    severity: "blocker",
    action: "Убрать старый бренд/домен и проверить, что ссылка не попадает в sitemap/страницу."
  },
  {
    id: "old_docs",
    label: "старые docs-ссылки",
    terms: ["docs.ss", "ss-docs", "old docs", "legacy docs", "docs.federat"],
    severity: "warning",
    action: "Проверить ссылку: оставить только актуальные NodeDC docs или убрать."
  }
];

const PRIORITY_WEIGHT = {
  high: 3,
  low: 1,
  medium: 2
} satisfies Record<SemanticClusterDefinition["priority"], number>;

const RUSSIAN_STOP_WORDS = new Set([
  "а",
  "без",
  "более",
  "будет",
  "бы",
  "в",
  "вам",
  "ваш",
  "во",
  "все",
  "для",
  "до",
  "его",
  "ее",
  "если",
  "есть",
  "и",
  "или",
  "из",
  "к",
  "как",
  "ко",
  "мы",
  "на",
  "над",
  "не",
  "но",
  "о",
  "об",
  "от",
  "по",
  "под",
  "при",
  "с",
  "со",
  "так",
  "то",
  "у",
  "уже",
  "assets",
  "attr",
  "avif",
  "class",
  "collection",
  "content",
  "cta",
  "cviz",
  "data",
  "features",
  "footer",
  "html",
  "image",
  "images",
  "json",
  "heading",
  "main",
  "media",
  "muted",
  "site",
  "src",
  "text",
  "txt",
  "webp",
  "блок",
  "заголовок",
  "иконка",
  "информационный",
  "карточка",
  "основная",
  "приглушенная",
  "пункт",
  "пункты",
  "подпись",
  "строка",
  "текст",
  "txt-файл",
  "txt-файла",
  "форма",
  "ячейка",
  "что",
  "это"
]);

const CTA_PATTERNS = [
  "запросить демо",
  "получить демо",
  "оставить заявку",
  "обсудить проект",
  "начать пилот",
  "подключиться",
  "связаться",
  "получить консультацию",
  "заказать"
];

const INTENT_SIGNAL_GROUPS = {
  agents: ["агент", "агентная", "ai agent", "автономный", "ассистент", "toolpack", "codex"],
  ai: ["искусственный интеллект", "ai", "ии", "llm", "нейросеть", "модель", "prompt", "генеративный"],
  automation: ["автоматизация", "автоматизировать", "workflow", "bpm", "регламент", "сквозной", "маршрут"],
  business: ["бизнес", "компания", "команда", "клиент", "заказчик", "операционный", "подразделение"],
  commercial: ["демо", "пилот", "стоимость", "тариф", "внедрение", "заявка", "консультация", "коммерческое предложение"],
  data: ["данные", "база", "хранилище", "интеграция данных", "аналитика", "сущность", "dataset"],
  development: ["разработка", "приложение", "код", "api", "frontend", "backend", "low-code", "no-code"],
  engineering: ["bim", "digital twin", "цифровой двойник", "3d", "step", "stl", "las", "инженерный"],
  enterprise: ["enterprise", "b2b", "корпоративный", "on-prem", "private cloud", "частное облако", "контур"],
  implementation: ["внедрение", "развертывание", "deploy", "инфраструктура", "сервер", "docker", "production"],
  integrations: ["интеграция", "api", "webhook", "1с", "crm", "erp", "битрикс", "telegram"],
  operations: ["ops", "tasker", "задача", "статус", "чекер", "исполнитель", "проектный офис"],
  process: ["процесс", "бизнес-процесс", "согласование", "закупка", "договор", "тендер", "регламент"],
  security: ["безопасность", "доступ", "роль", "rbac", "изоляция", "права", "закрытый контур"]
} satisfies Record<IntentSignalGroupId, string[]>;

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

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

function normalizeSourcePath(value: string) {
  return path.posix.normalize(`/${value.replace(/\\/g, "/")}`).replace(/^\/+/, "");
}

function resolveLocalSourcePath(rootPath: string, sourcePath: string) {
  const normalizedRoot = path.resolve(rootPath);
  const resolvedPath = path.resolve(normalizedRoot, normalizeSourcePath(sourcePath));
  const rootWithSeparator = normalizedRoot.endsWith(path.sep) ? normalizedRoot : `${normalizedRoot}${path.sep}`;

  if (resolvedPath !== normalizedRoot && !resolvedPath.startsWith(rootWithSeparator)) {
    throw new SemanticAnalysisError(`Небезопасный путь источника: ${sourcePath}`);
  }

  return resolvedPath;
}

async function readSourceText(source: LatestScanRow, sourcePath: string) {
  if (source.source_type === "local_folder") {
    const rootPath = getStringConfig(source.source_config, "path");

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

    return fs.readFile(resolveLocalSourcePath(rootPath, sourcePath), "utf8");
  }

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

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

    const normalizedPrefix = importPrefix.endsWith("/") ? importPrefix : `${importPrefix}/`;
    return storageProvider.getObjectText(`${normalizedPrefix}${normalizeSourcePath(sourcePath)}`);
  }

  throw new SemanticAnalysisError(`Источник ${source.source_type} пока не поддержан semantic analysis.`);
}

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 visibleTextFromHtml(html: string) {
  const body = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html)?.[1] ?? html;

  return decodeHtmlEntities(
    body
      .replace(/<script\b[\s\S]*?<\/script>/gi, " ")
      .replace(/<style\b[\s\S]*?<\/style>/gi, " ")
      .replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
      .replace(/<svg\b[\s\S]*?<\/svg>/gi, " ")
      .replace(/<[^>]+>/g, " ")
  )
    .replace(/\s+/g, " ")
    .trim();
}

function normalizeSearchText(value: string) {
  return value
    .toLocaleLowerCase("ru-RU")
    .replace(/ё/g, "е")
    .replace(/[«»“”]/g, "\"")
    .replace(/\s+/g, " ")
    .trim();
}

function countWords(value: string) {
  const words = normalizeSearchText(value).split(/\s+/).filter(Boolean);

  return words.length;
}

function getTextWords(value: string) {
  return normalizeSearchText(value).match(/[a-zа-я0-9-]{3,}/giu) ?? [];
}

function isTechnicalTextValue(value: string) {
  const normalizedValue = normalizeSearchText(value);

  return (
    /\{\{[^}]+}}/.test(value) ||
    /<[^>]+>/.test(value) ||
    /\b(class|src|srcset|href|data-[\w-]+)\s*=/.test(normalizedValue) ||
    /(^|\/)(assets|images|fonts|scripts|styles|dist|node_modules)\//.test(normalizedValue) ||
    /\.(png|jpe?g|webp|avif|svg|gif|ico|mp4|webm|css|js|mjs|woff2?|ttf|otf)(\?|#|$)/i.test(normalizedValue) ||
    /\b[0-9a-f]{16,}\b/i.test(normalizedValue) ||
    (/^[a-z0-9_-]{1,24}$/i.test(normalizedValue) && /[-_0-9]/.test(normalizedValue))
  );
}

function getSentences(value: string) {
  return value
    .split(/[.!?。]+/g)
    .map((sentence) => sentence.replace(/\s+/g, " ").trim())
    .filter((sentence) => countWords(sentence) >= 3);
}

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

function countTermHits(text: string, term: string) {
  const normalizedTerm = normalizeSearchText(term);

  if (!normalizedTerm) {
    return 0;
  }

  const escapedTerm = escapeRegExp(normalizedTerm).replace(/\\ /g, "\\s+");
  const isAsciiToken = /^[a-z0-9-]+$/i.test(normalizedTerm);
  const pattern = isAsciiToken
    ? new RegExp(`(^|[^a-z0-9-])${escapedTerm}(?=$|[^a-z0-9-])`, "g")
    : new RegExp(escapedTerm, "g");

  return Array.from(text.matchAll(pattern)).length;
}

function clampScore(value: number) {
  return Math.max(0, Math.min(100, Math.round(value)));
}

function getIntentGroupHits(text: string, groupId: IntentSignalGroupId) {
  return INTENT_SIGNAL_GROUPS[groupId].reduce((sum, term) => sum + countTermHits(text, term), 0);
}

function getClusterIntentMatches(cluster: SemanticClusterDefinition, documents: ContentDocument[]) {
  const groupHits = new Map<IntentSignalGroupId, number>();

  for (const groupId of cluster.intentGroups) {
    const hits = documents.reduce((sum, document) => sum + getIntentGroupHits(normalizeSearchText(document.text), groupId), 0);

    if (hits > 0) {
      groupHits.set(groupId, hits);
    }
  }

  const matchedIntentGroups = cluster.intentGroups.filter((groupId) => (groupHits.get(groupId) ?? 0) > 0);
  const missingIntentGroups = cluster.intentGroups.filter((groupId) => !matchedIntentGroups.includes(groupId));

  return {
    matchedIntentGroups,
    missingIntentGroups,
    groupHits
  };
}

function getEvidenceLevel(
  score: number,
  landingEvidenceUnits: number,
  matchedIntentGroups: number
): SemanticAnalysisOutput["clusters"][number]["evidence"]["level"] {
  if (score >= 78 && landingEvidenceUnits > 0 && matchedIntentGroups >= 2) {
    return "strong";
  }

  if (score >= 58 && landingEvidenceUnits > 0 && matchedIntentGroups >= 1) {
    return "moderate";
  }

  if (score > 0) {
    return "thin";
  }

  return "none";
}

function normalizeUrlLikePath(value: string) {
  const normalized = `/${normalizeSourcePath(value)}`.replace(/\/index\.html$/i, "/").replace(/\.html$/i, "");

  return normalized.length > 1 ? normalized.replace(/\/+$/g, "") : "/";
}

function formatLandingTargetPath(value: string) {
  const normalized = normalizeUrlLikePath(value);

  return normalized === "/" ? normalized : `${normalized}/`;
}

function documentMatchesTargetPage(document: ContentDocument, targetPage: string) {
  const target = normalizeUrlLikePath(targetPage);
  const urlPath = document.urlPath ? normalizeUrlLikePath(document.urlPath) : normalizeUrlLikePath(document.sourcePath);

  return target === "/" ? urlPath === "/" : urlPath === target || urlPath.startsWith(`${target}/`);
}

function buildEvidenceSnippet(document: ContentDocument, terms: string[]) {
  const flatText = document.text.replace(/\s+/g, " ").trim();
  const normalizedFlatText = normalizeSearchText(flatText);
  const firstTerm = terms.find((term) => normalizedFlatText.includes(normalizeSearchText(term)));
  const termIndex = firstTerm ? normalizedFlatText.indexOf(normalizeSearchText(firstTerm)) : -1;
  const snippetStart = termIndex >= 0 ? Math.max(0, termIndex - 110) : 0;
  const snippetEnd = termIndex >= 0 && firstTerm ? Math.min(flatText.length, termIndex + firstTerm.length + 170) : 260;
  const rawSnippet = flatText.slice(snippetStart, snippetEnd).trim();
  const normalizedSnippet = normalizeSearchText(rawSnippet);
  const matchedTerms = terms.filter((term) => countTermHits(normalizedSnippet, term) > 0);
  const text = `${snippetStart > 0 ? "... " : ""}${rawSnippet}${snippetEnd < flatText.length ? " ..." : ""}`;

  return {
    sourceId: document.id,
    sourceKind: document.kind,
    sourcePath: document.sourcePath,
    title: document.title,
    text,
    matchedTerms
  };
}

function buildStyleProfile(documents: ContentDocument[]): SemanticAnalysisOutput["styleProfile"] {
  const text = documents.map((document) => document.text).join("\n");
  const normalizedText = normalizeSearchText(text);
  const cyrillicCount = (normalizedText.match(/[а-я]/g) ?? []).length;
  const latinCount = (normalizedText.match(/[a-z]/g) ?? []).length;
  const sentences = getSentences(text);
  const averageSentenceWords =
    sentences.length === 0
      ? 0
      : Math.round(sentences.reduce((sum, sentence) => sum + countWords(sentence), 0) / sentences.length);
  const wordCounts = new Map<string, number>();

  for (const word of getTextWords(text)) {
    if (RUSSIAN_STOP_WORDS.has(word) || /^\d+$/.test(word) || /^[0-9]+[a-z]+$/i.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
      continue;
    }

    wordCounts.set(word, (wordCounts.get(word) ?? 0) + 1);
  }

  const topTerms = [...wordCounts.entries()]
    .filter(([, count]) => count >= 2)
    .sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0], "ru-RU"))
    .slice(0, 18)
    .map(([term, count]) => ({ term, count }));
  const knownBrandTerms = ["nodedc", "node.dc", "node dc", "node.js", "нодедс", "ии", "ai", "ops", "hub", "engine"];
  const brandTerms = knownBrandTerms.filter((term) => countTermHits(normalizedText, term) > 0);
  const ctaPhrases = CTA_PATTERNS.filter((phrase) => countTermHits(normalizedText, phrase) > 0);
  const toneSignals = [
    topTerms.some((item) => ["платформа", "контур", "инфраструктура", "процесс"].includes(item.term))
      ? "инженерно-продуктовый тон"
      : "",
    topTerms.some((item) => ["бизнес", "команда", "проект", "задача"].includes(item.term))
      ? "B2B/операционный язык"
      : "",
    brandTerms.some((term) => ["ai", "ии"].includes(term)) ? "AI-first лексика" : "",
    ctaPhrases.length > 0 ? "есть коммерческие CTA" : ""
  ].filter(Boolean);

  return {
    language: cyrillicCount === 0 ? "unknown" : latinCount > cyrillicCount * 0.25 ? "mixed" : "ru",
    averageSentenceWords,
    topTerms,
    brandTerms,
    ctaPhrases,
    toneSignals
  };
}

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

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

  return headings.flatMap((heading) => {
    if (!heading || typeof heading !== "object") {
      return [];
    }

    const record = heading as Record<string, unknown>;

    return typeof record.text === "string" ? [record.text] : [];
  });
}

function getPageMissingCriticalFields(page: PageRow) {
  const missingFields: string[] = [];
  const headings = normalizeHeadings(page.raw);

  if (!page.title?.trim()) {
    missingFields.push("title");
  }

  if (!page.description?.trim()) {
    missingFields.push("description");
  }

  if (!headings.some((heading) => heading.trim())) {
    missingFields.push("headings");
  }

  return missingFields;
}

function classifyPageScope(page: PageRow): { scope: PageScope; reason: string } {
  const sourcePath = normalizeSourcePath(page.source_path).toLocaleLowerCase("ru-RU");
  const 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-страницу сайта, которую можно оценивать как посадочную."
  };
}

async function buildPageDocument(source: LatestScanRow, page: PageRow): Promise<ContentDocument> {
  let htmlText = "";

  try {
    htmlText = visibleTextFromHtml(await readSourceText(source, page.source_path));
  } catch {
    htmlText = "";
  }

  const headings = normalizeHeadings(page.raw);
  const title = page.title?.trim() || page.url_path || page.source_path;
  const text = [page.title, page.description, ...headings, htmlText].filter(Boolean).join("\n");
  const pageScope = classifyPageScope(page);

  return {
    id: page.page_id,
    kind: "page",
    selected: page.selected,
    scope: pageScope.scope,
    scopeReason: pageScope.reason,
    usedForCoverage: page.selected && pageScope.scope === "indexable_page",
    title,
    sourcePath: page.source_path,
    urlPath: page.url_path,
    text,
    wordCount: countWords(text)
  };
}

function shouldUseJsonContentSource(sourcePath: string) {
  const normalizedPath = normalizeSourcePath(sourcePath).toLocaleLowerCase("ru-RU");
  const baseName = path.posix.basename(normalizedPath);

  if (!normalizedPath.endsWith(".json")) {
    return false;
  }

  if (/(^|\/)(content|copy|seo|pages|src\/content|app\/content)\//.test(normalizedPath)) {
    return true;
  }

  return /(home|landing|seo|copy|page|content)/.test(baseName);
}

function collectJsonStrings(value: unknown, result: string[] = []) {
  if (typeof value === "string") {
    const trimmed = value.trim();

    if (
      trimmed.length >= 3 &&
      trimmed.length <= 3000 &&
      !isTechnicalTextValue(trimmed) &&
      !/^#[0-9a-f]{3,8}$/i.test(trimmed) &&
      !/^[\w.-]+\.(png|jpe?g|webp|avif|svg|mp4|webm|css|js)$/i.test(trimmed)
    ) {
      result.push(trimmed);
    }

    return result;
  }

  if (Array.isArray(value)) {
    value.forEach((item) => collectJsonStrings(item, result));
    return result;
  }

  if (value && typeof value === "object") {
    Object.values(value).forEach((item) => collectJsonStrings(item, result));
  }

  return result;
}

async function buildContentJsonDocument(source: LatestScanRow, sourcePath: string): Promise<ContentDocument | null> {
  if (!shouldUseJsonContentSource(sourcePath)) {
    return null;
  }

  try {
    const rawText = await readSourceText(source, sourcePath);
    const parsed = JSON.parse(rawText) as unknown;
    const strings = collectJsonStrings(parsed);
    const text = strings.join("\n").slice(0, 200_000);

    if (!text.trim()) {
      return null;
    }

    return {
      id: `content:${sourcePath}`,
      kind: "content_json",
      selected: true,
      scope: "content_source",
      scopeReason: "JSON-контент используется как смысловой источник сайта, но не считается отдельной HTML-страницей.",
      usedForCoverage: true,
      title: path.posix.basename(sourcePath),
      sourcePath,
      urlPath: null,
      text,
      wordCount: countWords(text)
    };
  } catch {
    return null;
  }
}

function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDocument) {
  const normalizedText = normalizeSearchText(document.text);
  let hits = 0;
  const matchedTerms: string[] = [];

  for (const keyword of cluster.keywords) {
    const keywordHits = countTermHits(normalizedText, keyword);

    if (keywordHits > 0) {
      hits += keywordHits;
      matchedTerms.push(keyword);
    }
  }

  return { hits, matchedTerms };
}

function analyzeClusters(documents: ContentDocument[]) {
  return NODEDC_CLUSTERS.map((cluster) => {
    const docHits = documents
      .map((document) => {
        const result = getClusterHits(cluster, document);

        return {
          ...result,
          document
        };
      })
      .filter((item) => item.hits > 0)
      .sort((first, second) => second.hits - first.hits);
    const hits = docHits.reduce((sum, item) => sum + item.hits, 0);
    const hitDocuments = docHits.map((item) => item.document);
    const matchedTerms = Array.from(new Set(docHits.flatMap((item) => item.matchedTerms)));
    const missingTerms = cluster.keywords.filter((keyword) => !matchedTerms.includes(keyword));
    const lexicalScore = hits === 0 ? 0 : clampScore(Math.max((matchedTerms.length / cluster.keywords.length) * 100, hits * 8));
    const indexableEvidence = docHits.filter((item) => item.document.scope === "indexable_page");
    const meaningfulIndexableEvidence = indexableEvidence.filter((item) => item.document.wordCount >= 120 && item.hits >= 2);
    const targetPageEvidence = indexableEvidence.filter((item) =>
      cluster.targetPages.some((targetPage) => documentMatchesTargetPage(item.document, targetPage))
    );
    const contentSourceEvidence = docHits.filter((item) => item.document.scope === "content_source");
    const landingPageHits = indexableEvidence.reduce((sum, item) => sum + item.hits, 0);
    const meaningfulLandingPageHits = meaningfulIndexableEvidence.reduce((sum, item) => sum + item.hits, 0);
    const contentSourceHits = contentSourceEvidence.reduce((sum, item) => sum + item.hits, 0);
    const uniqueSources = new Set(docHits.map((item) => item.document.id)).size;
    const intentMatches = getClusterIntentMatches(cluster, hitDocuments);
    const intentScore =
      cluster.intentGroups.length === 0
        ? 0
        : clampScore((intentMatches.matchedIntentGroups.length / cluster.intentGroups.length) * 100);
    const strongSnippetCount = docHits.filter(
      (item) => item.matchedTerms.length >= 2 && countWords(buildEvidenceSnippet(item.document, item.matchedTerms).text) >= 14
    ).length;
    const directLexicalScore = Math.min(42, lexicalScore * 0.42);
    const landingEvidenceScore = Math.min(
      24,
      meaningfulIndexableEvidence.length * 9 + targetPageEvidence.length * 8 + Math.min(meaningfulLandingPageHits, 8) * 0.9
    );
    const contentEvidenceScore = Math.min(10, contentSourceEvidence.length * 4 + Math.min(contentSourceHits, 8) * 0.75);
    const intentSupportScore = Math.min(18, intentScore * 0.18);
    const depthScore = Math.min(6, strongSnippetCount * 3 + Math.max(0, uniqueSources - 1));
    const supportSignals: string[] = [];
    const penalties: string[] = [];

    if (indexableEvidence.length > 0) {
      supportSignals.push(`посадочные источники: ${indexableEvidence.length}`);
    }

    if (targetPageEvidence.length > 0) {
      supportSignals.push(`целевые посадочные: ${targetPageEvidence.length}`);
    }

    if (contentSourceEvidence.length > 0) {
      supportSignals.push(`copy/json источники: ${contentSourceEvidence.length}`);
    }

    if (intentMatches.matchedIntentGroups.length > 0) {
      supportSignals.push(`intent: ${intentMatches.matchedIntentGroups.join(", ")}`);
    }

    if (hits > 0 && indexableEvidence.length === 0) {
      penalties.push("нет подтверждения на посадочных HTML");
    }

    if (hits > 0 && meaningfulIndexableEvidence.length === 0) {
      penalties.push("нет смысловой посадочной с достаточным текстом");
    }

    if (hits > 0 && targetPageEvidence.length === 0) {
      penalties.push("нет целевой посадочной из карты кластера");
    }

    if (hits > 0 && meaningfulIndexableEvidence.length <= 1 && targetPageEvidence.length === 0) {
      penalties.push("кластер держится на одной общей странице");
    }

    if (hits > 0 && matchedTerms.length <= 1) {
      penalties.push("слишком узкое lexical совпадение");
    }

    if (hits > 0 && intentMatches.matchedIntentGroups.length === 0) {
      penalties.push("нет intent-сигналов вокруг кластера");
    }

    if (cluster.priority === "high" && intentMatches.matchedIntentGroups.length < 2) {
      penalties.push("high-priority кластеру нужен минимум 2 intent-сигнала");
    }

    const penaltyScore = penalties.reduce((sum, penalty) => {
      if (penalty === "нет подтверждения на посадочных HTML") {
        return sum + 18;
      }

      if (penalty === "нет смысловой посадочной с достаточным текстом") {
        return sum + 20;
      }

      if (penalty === "нет целевой посадочной из карты кластера") {
        return sum + (cluster.priority === "high" ? 18 : 14);
      }

      if (penalty === "кластер держится на одной общей странице") {
        return sum + 10;
      }

      if (penalty === "слишком узкое lexical совпадение") {
        return sum + 12;
      }

      if (penalty === "нет intent-сигналов вокруг кластера") {
        return sum + 14;
      }

      return sum + 8;
    }, 0);
    const score = hits === 0 ? 0 : clampScore(directLexicalScore + landingEvidenceScore + contentEvidenceScore + intentSupportScore + depthScore - penaltyScore);
    const landingEvidenceUnits = targetPageEvidence.length > 0 ? targetPageEvidence.length : meaningfulIndexableEvidence.length - 1;
    const evidenceLevel = getEvidenceLevel(score, Math.max(0, landingEvidenceUnits), intentMatches.matchedIntentGroups.length);
    const status: SemanticAnalysisOutput["clusters"][number]["status"] =
      evidenceLevel === "strong" ? "covered" : hits > 0 ? "weak" : "missing";

    return {
      id: cluster.id,
      title: cluster.title,
      priority: cluster.priority,
      targetIntent: cluster.targetIntent,
      status,
      score,
      hits,
      matchedTerms,
      missingTerms,
      queryExamples: cluster.queryExamples,
      targetPages: cluster.targetPages,
      evidenceSnippets: docHits
        .slice(0, 3)
        .map((item) => buildEvidenceSnippet(item.document, item.matchedTerms))
        .filter((snippet) => snippet.text.length > 0),
      sourceRefs: docHits.slice(0, 5).map((item) => ({
        id: item.document.id,
        kind: item.document.kind,
        scope: item.document.scope,
        title: item.document.title,
        sourcePath: item.document.sourcePath,
        urlPath: item.document.urlPath,
        hits: item.hits
      })),
      evidence: {
        level: evidenceLevel,
        score,
        lexicalScore,
        intentScore,
        landingPageHits,
        contentSourceHits,
        uniqueSources,
        matchedIntentGroups: intentMatches.matchedIntentGroups,
        missingIntentGroups: intentMatches.missingIntentGroups,
        supportSignals,
        penalties
      }
    };
  });
}

function getCoverageScore(clusters: ReturnType<typeof analyzeClusters>) {
  return getWeightedClusterScore(clusters, (cluster) => cluster.score);
}

function getWeightedClusterScore(clusters: ReturnType<typeof analyzeClusters>, getScore: (cluster: ReturnType<typeof analyzeClusters>[number]) => number) {
  const weighted = clusters.reduce(
    (accumulator, cluster) => {
      const weight = PRIORITY_WEIGHT[cluster.priority];

      accumulator.score += getScore(cluster) * weight;
      accumulator.weight += weight;
      return accumulator;
    },
    { score: 0, weight: 0 }
  );

  return weighted.weight === 0 ? 0 : Math.round(weighted.score / weighted.weight);
}

function getIntentCoverageScore(clusters: ReturnType<typeof analyzeClusters>) {
  return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.intentScore);
}

function getLexicalCoverageScore(clusters: ReturnType<typeof analyzeClusters>) {
  return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.lexicalScore);
}

function getEvidenceScore(clusters: ReturnType<typeof analyzeClusters>) {
  return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.score);
}

function getCommercialReadinessScore(clusters: ReturnType<typeof analyzeClusters>, styleProfile: SemanticAnalysisOutput["styleProfile"]) {
  const commercialClusters = clusters.filter((cluster) =>
    ["business_process_automation", "integrations_api", "conversion_pilot_pricing", "private_cloud_security"].includes(cluster.id)
  );
  const clusterScore = getWeightedClusterScore(commercialClusters.length > 0 ? commercialClusters : clusters, (cluster) => cluster.score);
  const ctaScore = Math.min(20, styleProfile.ctaPhrases.length * 10);

  return clampScore(clusterScore * 0.8 + ctaScore);
}

function analyzeLegacy(documents: ContentDocument[]) {
  return documents.flatMap((document) => {
    const normalizedText = normalizeSearchText(document.text);

    return LEGACY_MARKERS.flatMap((marker) => {
      const count = marker.terms.reduce((sum, term) => sum + countTermHits(normalizedText, term), 0);

      if (count === 0) {
        return [];
      }

      return [
        {
          markerId: marker.id,
          label: marker.label,
          severity: marker.severity,
          sourceId: document.id,
          sourceKind: document.kind,
          title: document.title,
          sourcePath: document.sourcePath,
          urlPath: document.urlPath,
          count,
          action: marker.action
        }
      ];
    });
  });
}

function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>) {
  return clusters.flatMap((cluster) => {
    const source: SemanticAnalysisOutput["seedCandidates"][number]["source"] =
      cluster.status === "missing" ? "ontology" : "detected";
    const reason =
      cluster.status === "missing"
        ? "Кластер важен для NodeDC, но в текущем сайте почти не раскрыт."
        : "Кластер уже найден в контенте и подходит для проверки частотности.";

    return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({
      phrase,
      clusterId: cluster.id,
      clusterTitle: cluster.title,
      source,
      reason,
      priority: cluster.priority
    }));
  });
}

function getLandingOpportunityPageType(
  pagePath: string
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
  const normalizedPath = normalizeUrlLikePath(pagePath);

  if (normalizedPath === "/") {
    return "home";
  }

  if (normalizedPath.includes("integrations") || normalizedPath.includes("1c")) {
    return "integration";
  }

  if (normalizedPath.includes("security")) {
    return "security";
  }

  if (normalizedPath.includes("deployment")) {
    return "deployment";
  }

  if (normalizedPath.includes("tariff") || normalizedPath.includes("pricing") || normalizedPath.includes("pilot") || normalizedPath.includes("contacts")) {
    return "conversion";
  }

  if (normalizedPath.includes("use-cases")) {
    return "use_case";
  }

  if (normalizedPath.includes("modules")) {
    return "module";
  }

  if (normalizedPath.includes("docs")) {
    return "docs";
  }

  return "landing";
}

function getSemanticPriorityWeight(priority: SemanticClusterDefinition["priority"]) {
  if (priority === "high") {
    return 100;
  }

  if (priority === "medium") {
    return 70;
  }

  return 45;
}

function pickHighestSemanticPriority(priorities: SemanticClusterDefinition["priority"][]): SemanticClusterDefinition["priority"] {
  if (priorities.includes("high")) {
    return "high";
  }

  if (priorities.includes("medium")) {
    return "medium";
  }

  return "low";
}

function buildLandingOpportunities(clusters: ReturnType<typeof analyzeClusters>, documents: ContentDocument[]) {
  const indexableDocuments = documents.filter((document) => document.scope === "indexable_page");

  return clusters.map((cluster) => {
    const currentLandingPages = Array.from(
      new Set(
        cluster.sourceRefs
          .filter((ref) => ref.scope === "indexable_page")
          .map((ref) => normalizeUrlLikePath(ref.urlPath ?? ref.sourcePath))
      )
    ).sort((first, second) => first.localeCompare(second, "ru-RU"));
    const existingTargetPages = cluster.targetPages.filter((targetPage) =>
      indexableDocuments.some((document) => documentMatchesTargetPage(document, targetPage))
    );
    const missingTargetPages = cluster.targetPages.filter((targetPage) => !existingTargetPages.includes(targetPage));
    const currentNonTargetPages = currentLandingPages.filter(
      (landingPage) => !cluster.targetPages.some((targetPage) => normalizeUrlLikePath(targetPage) === landingPage)
    );
    const primaryTargetPage = missingTargetPages[0] ?? existingTargetPages[0] ?? cluster.targetPages[0] ?? "/";
    const pageType = getLandingOpportunityPageType(primaryTargetPage);
    const action: SemanticAnalysisOutput["landingOpportunities"][number]["action"] =
      cluster.status === "covered" && missingTargetPages.length === 0
        ? "validate_external"
        : cluster.status === "covered"
          ? "keep_home"
          : missingTargetPages.length > 0
            ? "create_target_page"
            : "strengthen_existing";
    const recommendedPages =
      action === "create_target_page" || action === "keep_home"
        ? missingTargetPages.slice(0, 4).map((targetPage) => ({
            path: targetPage,
            pageType: getLandingOpportunityPageType(targetPage),
            action: "create" as const,
            reason:
              currentNonTargetPages.length > 0
                ? `Сейчас кластер найден на ${currentNonTargetPages.slice(0, 3).join(", ")}, но целевой страницы ${targetPage} нет.`
                : `Целевая страница ${targetPage} отсутствует в indexable scope.`
          }))
        : existingTargetPages.slice(0, 4).map((targetPage) => ({
            path: targetPage,
            pageType: getLandingOpportunityPageType(targetPage),
            action: (cluster.status === "covered" ? "validate" : "strengthen") as "strengthen" | "validate",
            reason:
              cluster.status === "covered"
                ? "Страница уже есть и кластер подтверждён; следующий шаг — Wordstat/SERP validation."
                : `Страница есть, но evidence ${cluster.evidence.level}; нужно усилить текст, интент и коммерческие блоки.`
          }));
    const reason =
      action === "validate_external"
        ? "Кластер уже имеет сильную локальную evidence. Дальше не расширять вслепую, а проверять спрос и конкурентов."
        : action === "keep_home"
          ? `Кластер можно оставить на главной как общий сигнал, но для SEO-охвата полезно вынести часть интента в ${missingTargetPages
              .slice(0, 3)
              .join(", ")}.`
          : action === "create_target_page"
            ? `Кластер словарно найден, но не имеет целевой посадочной из карты: ${missingTargetPages.slice(0, 3).join(", ")}.`
            : `Кластер имеет целевую страницу, но evidence ${cluster.evidence.level}; нужна доработка существующей посадочной.`;

    return {
      clusterId: cluster.id,
      title: cluster.title,
      priority: cluster.priority,
      status: cluster.status,
      score: cluster.score,
      evidenceLevel: cluster.evidence.level,
      targetIntent: cluster.targetIntent,
      pageType,
      action,
      currentLandingPages,
      targetPages: cluster.targetPages,
      existingTargetPages,
      missingTargetPages,
      recommendedPages,
      reason
    };
  });
}

function buildLandingPagePlan(
  landingOpportunities: SemanticAnalysisOutput["landingOpportunities"],
  clusters: ReturnType<typeof analyzeClusters>
): SemanticAnalysisOutput["landingPagePlan"] {
  const clusterById = new Map(clusters.map((cluster) => [cluster.id, cluster]));
  const planMap = new Map<
    string,
    {
      path: string;
      pageType: LandingOpportunityPageType;
      actions: Set<"create" | "strengthen" | "validate">;
      clusters: SemanticAnalysisOutput["landingPagePlan"][number]["clusters"];
      currentSources: Set<string>;
      seedPhrases: Set<string>;
    }
  >();

  for (const opportunity of landingOpportunities) {
    const cluster = clusterById.get(opportunity.clusterId);
    const existingPageActions = opportunity.existingTargetPages.map((targetPage) => ({
      path: targetPage,
      pageType: getLandingOpportunityPageType(targetPage),
      action: (opportunity.status === "covered" ? "validate" : "strengthen") as "strengthen" | "validate"
    }));
    const recommendedPageActions = opportunity.recommendedPages.map((page) => ({
      path: page.path,
      pageType: page.pageType,
      action: page.action
    }));
    const fallbackPageActions =
      existingPageActions.length === 0 && recommendedPageActions.length === 0
        ? opportunity.targetPages.slice(0, 1).map((targetPage) => ({
            path: targetPage,
            pageType: getLandingOpportunityPageType(targetPage),
            action: "create" as const
          }))
        : [];

    for (const page of [...existingPageActions, ...recommendedPageActions, ...fallbackPageActions]) {
      const normalizedPath = normalizeUrlLikePath(page.path);
      const plan =
        planMap.get(normalizedPath) ??
        {
          path: formatLandingTargetPath(normalizedPath),
          pageType: page.pageType,
          actions: new Set<"create" | "strengthen" | "validate">(),
          clusters: [],
          currentSources: new Set<string>(),
          seedPhrases: new Set<string>()
        };

      plan.actions.add(page.action);
      plan.clusters.push({
        clusterId: opportunity.clusterId,
        title: opportunity.title,
        priority: opportunity.priority,
        status: opportunity.status,
        score: opportunity.score,
        evidenceLevel: opportunity.evidenceLevel,
        action: opportunity.action,
        targetIntent: opportunity.targetIntent
      });

      for (const sourcePath of opportunity.currentLandingPages) {
        plan.currentSources.add(sourcePath);
      }

      for (const phrase of cluster?.queryExamples ?? []) {
        plan.seedPhrases.add(phrase);
      }

      planMap.set(normalizedPath, plan);
    }
  }

  return Array.from(planMap.values())
    .map((plan) => {
      const uniqueClusters = Array.from(
        new Map(plan.clusters.map((cluster) => [cluster.clusterId, cluster])).values()
      ).sort((first, second) => {
        const priorityDiff = getSemanticPriorityWeight(second.priority) - getSemanticPriorityWeight(first.priority);
        return priorityDiff !== 0 ? priorityDiff : second.score - first.score;
      });
      const action: SemanticAnalysisOutput["landingPagePlan"][number]["action"] = plan.actions.has("create")
        ? "create"
        : plan.actions.has("strengthen")
          ? "strengthen"
          : "validate";
      const readinessScore = clampScore(
        uniqueClusters.reduce((sum, cluster) => sum + cluster.score, 0) / Math.max(1, uniqueClusters.length)
      );
      const priority = pickHighestSemanticPriority(uniqueClusters.map((cluster) => cluster.priority));
      const priorityScore = Math.max(...uniqueClusters.map((cluster) => getSemanticPriorityWeight(cluster.priority)), 0);
      const weakClusterCount = uniqueClusters.filter((cluster) => cluster.status === "weak").length;
      const missingClusterCount = uniqueClusters.filter((cluster) => cluster.status === "missing").length;
      const coveredClusterCount = uniqueClusters.filter((cluster) => cluster.status === "covered").length;
      const actionBoost = action === "create" ? 18 : action === "strengthen" ? 10 : 3;
      const opportunityScore = clampScore(
        priorityScore * 0.42 +
          Math.min(24, uniqueClusters.length * 6) +
          Math.min(18, (weakClusterCount + missingClusterCount) * 6) +
          (100 - readinessScore) * 0.12 +
          actionBoost
      );
      const reason =
        action === "create"
          ? `Создать посадочную ${plan.path}: она соберёт ${uniqueClusters.length} кластер(ов), включая ${uniqueClusters
              .slice(0, 3)
              .map((cluster) => cluster.title)
              .join(", ")}.`
          : action === "strengthen"
            ? `Усилить существующую страницу ${plan.path}: кластеры есть, но evidence/readiness пока недостаточны.`
            : `Страница ${plan.path} уже покрывает кластер(ы); следующий шаг — проверить спрос и конкурентов.`;

      return {
        path: plan.path,
        pageType: plan.pageType,
        action,
        priority,
        readinessScore,
        opportunityScore,
        coveredClusterCount,
        weakClusterCount,
        missingClusterCount,
        reason,
        currentSources: Array.from(plan.currentSources).slice(0, 5),
        seedPhrases: Array.from(plan.seedPhrases).slice(0, 8),
        clusters: uniqueClusters.slice(0, 6)
      };
    })
    .sort((first, second) => {
      const actionDiff =
        (second.action === "create" ? 2 : second.action === "strengthen" ? 1 : 0) -
        (first.action === "create" ? 2 : first.action === "strengthen" ? 1 : 0);

      if (actionDiff !== 0) {
        return actionDiff;
      }

      return second.opportunityScore - first.opportunityScore;
    });
}

function trimSeoText(value: string, maxLength: number) {
  const normalized = value.replace(/\s+/g, " ").trim();

  if (normalized.length <= maxLength) {
    return normalized;
  }

  const sliced = normalized.slice(0, maxLength - 1);
  const lastSpaceIndex = sliced.lastIndexOf(" ");

  return `${sliced.slice(0, lastSpaceIndex > 40 ? lastSpaceIndex : maxLength - 1).trim()}…`;
}

function getLandingBriefEntity(pagePath: string, pageType: LandingOpportunityPageType) {
  const normalizedPath = normalizeUrlLikePath(pagePath);
  const labels: Record<string, string> = {
    "/": "NodeDC",
    "/contacts": "Контакты NodeDC",
    "/deployment": "Внедрение NodeDC",
    "/docs": "Документация NodeDC",
    "/integrations": "Интеграции NodeDC",
    "/modules": "Модули NodeDC",
    "/modules/1c-assistant": "1С-ассистент NodeDC",
    "/modules/assistant": "AI-ассистенты NodeDC",
    "/modules/digital-twin": "Digital Twin NodeDC",
    "/modules/engine": "NDC Engine",
    "/modules/hub": "NDC Hub",
    "/modules/ops": "NDC Ops",
    "/modules/tender": "Тендерный модуль NodeDC",
    "/pilot": "Пилот NodeDC",
    "/platform": "Платформа NodeDC",
    "/security": "Безопасность NodeDC",
    "/tariffs": "Тарифы NodeDC",
    "/use-cases": "Сценарии NodeDC",
    "/use-cases/construction": "NodeDC для строительства",
    "/use-cases/legal": "NodeDC для юридических процессов",
    "/use-cases/procurement": "NodeDC для закупок",
    "/use-cases/project-office": "NodeDC для проектного офиса"
  };

  if (labels[normalizedPath]) {
    return labels[normalizedPath];
  }

  if (pageType === "module") {
    return "Модуль NodeDC";
  }

  if (pageType === "use_case") {
    return "Сценарий NodeDC";
  }

  if (pageType === "integration") {
    return "Интеграция NodeDC";
  }

  return "NodeDC";
}

function getLandingBriefAudience(pageType: LandingOpportunityPageType) {
  const audiences: Record<LandingOpportunityPageType, string> = {
    conversion: "ЛПР, которому нужно быстро понять формат пилота, стоимость и следующий шаг.",
    deployment: "IT-директор и архитектор, которые оценивают развертывание в корпоративном контуре.",
    docs: "Техническая команда, которая хочет понять подключение, API, модули и ограничения.",
    home: "Бизнес и технический ЛПР, который впервые оценивает платформу NodeDC.",
    integration: "Руководитель автоматизации и интегратор, которым важны 1С, CRM/ERP, API и webhooks.",
    landing: "ЛПР по цифровизации, которому нужно связать проблему, решение и внедрение.",
    module: "Владелец процесса и техническая команда, которые выбирают конкретный модуль платформы.",
    security: "Enterprise/IT/security команда, которая проверяет доступы, контур, данные и риски.",
    use_case: "Владелец бизнес-процесса, которому нужен понятный сценарий применения NodeDC."
  };

  return audiences[pageType];
}

function getLandingBriefPrimaryCta(pageType: LandingOpportunityPageType) {
  if (pageType === "conversion") {
    return "Запросить расчет пилота";
  }

  if (pageType === "docs") {
    return "Открыть технический контур";
  }

  if (pageType === "security" || pageType === "deployment") {
    return "Обсудить развертывание";
  }

  return "Запросить демо";
}

function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) {
  if (pageType === "integration") {
    return "Показать интеграционный сценарий";
  }

  if (pageType === "module" || pageType === "use_case") {
    return "Собрать пилотный сценарий";
  }

  return "Получить консультацию";
}

function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType) {
  const normalizedPath = normalizeUrlLikePath(pagePath);
  const baseLinks = ["/", "/platform/", "/modules/", "/integrations/", "/deployment/", "/security/", "/pilot/", "/contacts/"];
  const typedLinks: Record<LandingOpportunityPageType, string[]> = {
    conversion: ["/platform/", "/modules/", "/contacts/"],
    deployment: ["/security/", "/docs/", "/integrations/"],
    docs: ["/modules/", "/integrations/", "/deployment/"],
    home: ["/platform/", "/modules/", "/pilot/"],
    integration: ["/modules/engine/", "/security/", "/pilot/"],
    landing: ["/platform/", "/modules/", "/pilot/"],
    module: ["/integrations/", "/deployment/", "/security/", "/pilot/"],
    security: ["/deployment/", "/docs/", "/contacts/"],
    use_case: ["/modules/ops/", "/integrations/", "/pilot/"]
  };

  return Array.from(new Set([...typedLinks[pageType], ...baseLinks]))
    .filter((link) => normalizeUrlLikePath(link) !== normalizedPath)
    .slice(0, 5);
}

function getLandingBriefTypeSection(pageType: LandingOpportunityPageType) {
  const sections: Record<LandingOpportunityPageType, { title: string; goal: string }> = {
    conversion: {
      title: "Формат пилота и следующий шаг",
      goal: "Показать, как начинается проект, что входит в пилот и какие данные нужны для оценки."
    },
    deployment: {
      title: "Архитектура внедрения",
      goal: "Раскрыть контур, инфраструктуру, этапы развертывания и ответственность сторон."
    },
    docs: {
      title: "Как подключиться",
      goal: "Дать технический вход: модули, API, интеграции, роли и ограничения."
    },
    home: {
      title: "Платформа и модули",
      goal: "Связать общий value proposition с конкретными модулями и сценариями."
    },
    integration: {
      title: "Системы и API",
      goal: "Показать подключение к 1С, CRM/ERP, API, webhooks и внешним контурам."
    },
    landing: {
      title: "Как NodeDC закрывает задачу",
      goal: "Собрать проблему, решение, результат и следующий коммерческий шаг."
    },
    module: {
      title: "Как работает модуль",
      goal: "Объяснить входные данные, действия модуля, роли, статусы и результат для бизнеса."
    },
    security: {
      title: "Контур, доступы и данные",
      goal: "Раскрыть безопасность, роли, RBAC, изоляцию, хранение данных и аудит."
    },
    use_case: {
      title: "Сценарий до и после внедрения",
      goal: "Показать текущую боль процесса, целевой workflow и измеримый эффект."
    }
  };

  return sections[pageType];
}

type LandingBriefDraft = {
  h1: string;
  title: string;
  metaDescription: string;
  targetIntent: string;
  primaryCta: string;
  secondaryCta: string;
  mustUseTerms: string[];
  seedPhrases: string[];
  internalLinks: string[];
  sections: Array<{
    title: string;
    goal: string;
    mustCoverTerms: string[];
  }>;
  readinessChecklist: Array<{
    id: string;
    title: string;
    done: boolean;
    reason: string;
  }>;
};

function getBriefCheckScore(status: BriefQualityContract["checks"][number]["status"]) {
  if (status === "pass") {
    return 100;
  }

  if (status === "warn") {
    return 55;
  }

  return 0;
}

function getTextLengthCheck(
  id: string,
  title: string,
  length: number,
  idealMin: number,
  idealMax: number,
  hardMin: number,
  hardMax: number
): BriefQualityContract["checks"][number] {
  if (length >= idealMin && length <= idealMax) {
    return {
      id,
      title,
      status: "pass",
      reason: `${length} символов: попадает в рабочий SEO-диапазон.`
    };
  }

  if (length >= hardMin && length <= hardMax) {
    return {
      id,
      title,
      status: "warn",
      reason: `${length} символов: допустимо, но перед публикацией стоит отжать формулировку.`
    };
  }

  return {
    id,
    title,
    status: "fail",
    reason: `${length} символов: выходит за безопасный диапазон для сниппета.`
  };
}

function buildLandingBriefQualityContract(
  page: SemanticAnalysisOutput["landingPagePlan"][number],
  brief: LandingBriefDraft
): BriefQualityContract {
  const checklistDone = brief.readinessChecklist.filter((item) => item.done).length;
  const checklistPending = brief.readinessChecklist.length - checklistDone;
  const metrics: BriefQualityContract["metrics"] = {
    h1Length: brief.h1.length,
    titleLength: brief.title.length,
    metaDescriptionLength: brief.metaDescription.length,
    clusterCount: page.clusters.length,
    sectionCount: brief.sections.length,
    seedPhraseCount: brief.seedPhrases.length,
    mustUseTermCount: brief.mustUseTerms.length,
    internalLinkCount: brief.internalLinks.length,
    checklistDone,
    checklistPending
  };
  const checks: BriefQualityContract["checks"] = [
    getTextLengthCheck("h1_length", "H1 в рабочем диапазоне", metrics.h1Length, 24, 96, 14, 110),
    getTextLengthCheck("title_length", "Title в рабочем диапазоне", metrics.titleLength, 42, 72, 28, 82),
    getTextLengthCheck("meta_length", "Meta description в рабочем диапазоне", metrics.metaDescriptionLength, 115, 158, 85, 170),
    {
      id: "intent_focus",
      title: "Интент не расползается",
      status: metrics.clusterCount > 0 && metrics.clusterCount <= 3 ? "pass" : metrics.clusterCount <= 5 ? "warn" : "fail",
      reason:
        metrics.clusterCount === 0
          ? "У страницы нет привязанных кластеров."
          : metrics.clusterCount <= 3
            ? `Кластеров: ${metrics.clusterCount}; бриф можно держать в одном фокусе.`
            : `Кластеров: ${metrics.clusterCount}; нужен главный интент и вторичные секции.`
    },
    {
      id: "section_plan",
      title: "Структура страницы собрана",
      status:
        metrics.sectionCount >= 4 && brief.sections.every((section) => section.goal.trim().length > 0)
          ? "pass"
          : metrics.sectionCount >= 3
            ? "warn"
            : "fail",
      reason:
        metrics.sectionCount >= 4
          ? `Секций: ${metrics.sectionCount}; есть базовая структура первого экрана, интентов, типа страницы и CTA.`
          : `Секций: ${metrics.sectionCount}; структуры недостаточно для уверенного рерайта.`
    },
    {
      id: "seed_phrases",
      title: "Seed-фразы есть",
      status: metrics.seedPhraseCount >= 4 ? "pass" : metrics.seedPhraseCount > 0 ? "warn" : "fail",
      reason:
        metrics.seedPhraseCount >= 4
          ? `Seed-фраз: ${metrics.seedPhraseCount}; этого достаточно для первой Wordstat/SERP итерации.`
          : metrics.seedPhraseCount > 0
            ? `Seed-фраз: ${metrics.seedPhraseCount}; мало для устойчивой внешней проверки.`
            : "Seed-фразы отсутствуют."
    },
    {
      id: "internal_links",
      title: "Внутренняя перелинковка задана",
      status: metrics.internalLinkCount >= 4 ? "pass" : metrics.internalLinkCount >= 2 ? "warn" : "fail",
      reason:
        metrics.internalLinkCount >= 4
          ? `Внутренних ссылок: ${metrics.internalLinkCount}; есть маршруты для связки посадочных.`
          : `Внутренних ссылок: ${metrics.internalLinkCount}; нужно усилить навигационную связность.`
    },
    {
      id: "commercial_path",
      title: "Коммерческий путь указан",
      status: brief.primaryCta && brief.secondaryCta ? "pass" : "fail",
      reason:
        brief.primaryCta && brief.secondaryCta
          ? `CTA: ${brief.primaryCta}; запасной CTA: ${brief.secondaryCta}.`
          : "В брифе нет полного коммерческого пути."
    },
    {
      id: "implementation_readiness",
      title: "Готовность к применению понятна",
      status: checklistPending === 0 ? "pass" : checklistPending <= 3 ? "warn" : "fail",
      reason:
        checklistPending === 0
          ? "Все внутренние пункты готовности закрыты."
          : `Открытых пунктов: ${checklistPending}; их нужно закрыть до auto-apply.`
    }
  ];
  const riskFlags: BriefQualityContract["riskFlags"] = [];

  if (page.action === "create") {
    riskFlags.push({
      id: "page_missing",
      level: "problem",
      title: "Страницы нет",
      message: "Бриф описывает новую посадочную: для результата нужен route/page shell, а не только правка текста."
    });
  }

  if (page.action === "strengthen") {
    riskFlags.push({
      id: "existing_page_weak",
      level: "warning",
      title: "Нужно усиление",
      message: "Страница существует, но deterministic evidence/readiness недостаточны."
    });
  }

  if (metrics.clusterCount > 3) {
    riskFlags.push({
      id: "multi_intent",
      level: "warning",
      title: "Много интентов",
      message: "При рерайте нужно явно разделить основной интент, вторичные блоки и возможные дочерние страницы."
    });
  }

  if (page.readinessScore < 45) {
    riskFlags.push({
      id: "thin_evidence",
      level: "warning",
      title: "Слабое evidence",
      message: "Текущий сайт даёт мало доказательств по кластерам; нужен внешний спрос и конкурентный SERP."
    });
  }

  const missingEvidence = [
    "Wordstat частотность и сезонность по seed-фразам",
    "SERP-конкуренты и типы страниц в топе Яндекса",
    "Региональная выдача и коммерческие маркеры",
    "Фактические продуктовые доказательства: кейсы, интеграции, SLA, ограничения"
  ];
  const rawScore =
    checks.reduce((sum, check) => sum + getBriefCheckScore(check.status), 0) / Math.max(1, checks.length) -
    riskFlags.filter((risk) => risk.level === "problem").length * 10 -
    riskFlags.filter((risk) => risk.level === "warning").length * 4 -
    missingEvidence.length * 2;
  const score = clampScore(rawScore);
  const hasFailedStructuralCheck = checks.some(
    (check) => check.status === "fail" && check.id !== "implementation_readiness"
  );
  const hasProblemRisk = riskFlags.some((risk) => risk.level === "problem");
  const level: BriefQualityContract["level"] =
    score < 45 || hasFailedStructuralCheck ? "blocked" : score >= 78 && !hasProblemRisk ? "ready" : "needs_work";
  const summary =
    level === "ready"
      ? "Бриф детерминированно готов к следующей внешней проверке: структура, интент, CTA и перелинковка собраны."
      : level === "blocked"
        ? "Бриф пока нельзя отдавать в auto-режим: есть критичные пробелы в структуре, seed-фразах или применении."
        : "Бриф годится для dev-разбора, но перед финальным рерайтом нужно закрыть риски и внешние доказательства.";
  const nextStep =
    level === "ready"
      ? "Передать в Wordstat/SERP enrichment и проверить спрос/конкурентов."
      : "Закрыть failed/warn checks, затем прогнать Wordstat/SERP enrichment.";

  return {
    version: "brief-quality.v1",
    score,
    level,
    summary,
    nextStep,
    metrics,
    checks,
    riskFlags,
    missingEvidence
  };
}

function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landingPagePlan"]) {
  return landingPagePlan.map((page) => {
    const entity = getLandingBriefEntity(page.path, page.pageType);
    const primaryCluster = page.clusters[0];
    const clusterTitles = page.clusters.map((cluster) => cluster.title);
    const primaryIntent = primaryCluster?.targetIntent ?? page.reason;
    const mustUseTerms = Array.from(new Set([...page.seedPhrases.slice(0, 5), ...clusterTitles.slice(0, 4)])).slice(0, 8);
    const typeSection = getLandingBriefTypeSection(page.pageType);
    const h1 = trimSeoText(`${entity}: ${primaryCluster?.title ?? "AI-автоматизация для бизнеса"}`, 96);
    const title = trimSeoText(`${entity} — ${primaryCluster?.title ?? "AI-автоматизация"} | NodeDC`, 72);
    const metaDescription = trimSeoText(
      `${entity}: ${clusterTitles.slice(0, 2).join(", ")}. Сценарии, интеграции, безопасность и пилот внедрения NodeDC.`,
      158
    );
    const sections = [
      {
        title: "Первый экран",
        goal: `Сразу объяснить, какую задачу закрывает ${entity}, для кого страница и какой следующий шаг.`,
        mustCoverTerms: mustUseTerms.slice(0, 3)
      },
      {
        title: "Интенты и сценарии",
        goal: `Раскрыть кластеры: ${clusterTitles.slice(0, 4).join(", ")}.`,
        mustCoverTerms: clusterTitles.slice(0, 4)
      },
      {
        ...typeSection,
        mustCoverTerms: mustUseTerms.slice(2, 6)
      },
      {
        title: "Доказательства, внедрение и CTA",
        goal: "Показать интеграции, контур внедрения, безопасность, формат пилота и основной CTA.",
        mustCoverTerms: [getLandingBriefPrimaryCta(page.pageType), "пилот", "внедрение"].slice(0, 3)
      }
    ];

    const readinessChecklist = [
      {
        id: "page_exists",
        title: "Посадочная есть в indexable scope",
        done: page.action !== "create",
        reason:
          page.action === "create"
            ? "Страница отсутствует в текущем indexable scope."
            : "Страница уже есть или требует усиления/валидации."
      },
      {
        id: "intent_focus",
        title: "Интент сфокусирован",
        done: page.clusters.length > 0 && page.clusters.length <= 3,
        reason:
          page.clusters.length <= 3
            ? "Кластеров достаточно мало для одной понятной посадочной."
            : "Кластеров много; при написании нужно разделить основной и вторичные интенты."
      },
      {
        id: "seed_ready",
        title: "Seed-фразы подготовлены",
        done: page.seedPhrases.length > 0,
        reason:
          page.seedPhrases.length > 0
            ? `Есть ${page.seedPhrases.length} seed-фраз для Wordstat/SERP.`
            : "Seed-фразы пока не собраны для этой страницы."
      },
      {
        id: "commercial_block",
        title: "Коммерческий блок и CTA учтены",
        done: page.action === "validate",
        reason:
          page.action === "validate"
            ? "Страница выглядит готовой к внешней проверке."
            : "В брифе нужно явно добавить CTA, пилот/демо и путь к заявке."
      },
      {
        id: "market_validation",
        title: "Wordstat/SERP проверены",
        done: false,
        reason: "Внешние сервисы ещё не подключены; это следующий слой после deterministic brief."
      }
    ];
    const briefDraft: LandingBriefDraft = {
      h1,
      title,
      metaDescription,
      targetIntent: primaryIntent,
      primaryCta: getLandingBriefPrimaryCta(page.pageType),
      secondaryCta: getLandingBriefSecondaryCta(page.pageType),
      mustUseTerms,
      seedPhrases: page.seedPhrases,
      internalLinks: getLandingBriefInternalLinks(page.path, page.pageType),
      sections,
      readinessChecklist
    };

    return {
      path: page.path,
      pageType: page.pageType,
      action: page.action,
      priority: page.priority,
      h1,
      title,
      metaDescription,
      targetIntent: primaryIntent,
      audience: getLandingBriefAudience(page.pageType),
      primaryCta: briefDraft.primaryCta,
      secondaryCta: briefDraft.secondaryCta,
      introAngle: `Страница должна связать ${clusterTitles.slice(0, 3).join(", ")} с понятным коммерческим сценарием NodeDC.`,
      mustUseTerms,
      seedPhrases: briefDraft.seedPhrases,
      internalLinks: briefDraft.internalLinks,
      sections,
      readinessChecklist,
      clusters: page.clusters.map((cluster) => ({
        clusterId: cluster.clusterId,
        title: cluster.title,
        status: cluster.status,
        priority: cluster.priority,
        evidenceLevel: cluster.evidenceLevel
      })),
      qualityContract: buildLandingBriefQualityContract(page, briefDraft)
    };
  });
}

function getLandingBriefDraftFromOutputBrief(brief: SemanticAnalysisOutput["landingPageBriefs"][number]): LandingBriefDraft {
  return {
    h1: brief.h1,
    title: brief.title,
    metaDescription: brief.metaDescription,
    targetIntent: brief.targetIntent,
    primaryCta: brief.primaryCta,
    secondaryCta: brief.secondaryCta,
    mustUseTerms: brief.mustUseTerms,
    seedPhrases: brief.seedPhrases,
    internalLinks: brief.internalLinks,
    sections: brief.sections,
    readinessChecklist: brief.readinessChecklist
  };
}

function buildFallbackLandingPlanForBrief(
  brief: SemanticAnalysisOutput["landingPageBriefs"][number]
): SemanticAnalysisOutput["landingPagePlan"][number] {
  return {
    path: brief.path,
    pageType: brief.pageType,
    action: brief.action,
    priority: brief.priority,
    readinessScore: 0,
    opportunityScore: 0,
    coveredClusterCount: brief.clusters.filter((cluster) => cluster.status === "covered").length,
    weakClusterCount: brief.clusters.filter((cluster) => cluster.status === "weak").length,
    missingClusterCount: brief.clusters.filter((cluster) => cluster.status === "missing").length,
    reason: "Fallback для старого semantic run без landing page plan контекста.",
    currentSources: [],
    seedPhrases: brief.seedPhrases,
    clusters: brief.clusters.map((cluster) => ({
      clusterId: cluster.clusterId,
      title: cluster.title,
      priority: cluster.priority,
      status: cluster.status,
      score: 0,
      evidenceLevel: cluster.evidenceLevel,
      action:
        brief.action === "create"
          ? "create_target_page"
          : brief.action === "strengthen"
            ? "strengthen_existing"
            : "validate_external",
      targetIntent: brief.targetIntent
    }))
  };
}

function hydrateLandingBriefQualityContracts(output: SemanticAnalysisOutput): SemanticAnalysisOutput {
  const planByPath = new Map(output.landingPagePlan.map((page) => [page.path, page]));

  return {
    ...output,
    landingPageBriefs: output.landingPageBriefs.map((brief) => {
      const runtimeBrief = brief as typeof brief & { qualityContract?: BriefQualityContract };

      if (runtimeBrief.qualityContract) {
        return brief;
      }

      const plan = planByPath.get(brief.path) ?? buildFallbackLandingPlanForBrief(brief);

      return {
        ...brief,
        qualityContract: buildLandingBriefQualityContract(plan, getLandingBriefDraftFromOutputBrief(brief))
      };
    })
  };
}

function buildPageScopeSummary(pageDocuments: ContentDocument[], contentDocuments: ContentDocument[]) {
  const indexablePageDocuments = pageDocuments.filter((document) => document.scope === "indexable_page");

  return {
    indexablePages: indexablePageDocuments.length,
    selectedIndexablePages: indexablePageDocuments.filter((document) => document.selected).length,
    unselectedIndexablePages: indexablePageDocuments.filter((document) => !document.selected).length,
    templateBlocks: pageDocuments.filter((document) => document.scope === "template_block").length,
    adminPages: pageDocuments.filter((document) => document.scope === "admin_page").length,
    technicalPages: pageDocuments.filter((document) => document.scope === "technical_page").length,
    contentSources: contentDocuments.length,
    coverageDocuments: [...pageDocuments, ...contentDocuments].filter((document) => document.usedForCoverage).length,
    excludedDocuments: pageDocuments.filter((document) => !document.usedForCoverage).length
  };
}

type SemanticAnalysisBaseOutput = Omit<SemanticAnalysisOutput, "analysisVerdict" | "nextActions" | "qualitySignals">;
type SemanticAnalysisQualityOutput = Omit<SemanticAnalysisOutput, "analysisVerdict" | "nextActions">;

function buildQualitySignals(output: SemanticAnalysisBaseOutput): AnalysisSignal[] {
  const signals: AnalysisSignal[] = [];

  signals.push({
    id: "scope",
    level: output.pageScope.selectedIndexablePages > 0 ? "ok" : "problem",
    title: "Scope страниц",
    message:
      output.pageScope.selectedIndexablePages > 0
        ? `В покрытие вошло выбранных посадочных: ${output.pageScope.selectedIndexablePages}/${output.pageScope.indexablePages}; исключено HTML-документов: ${output.pageScope.excludedDocuments}.`
        : "Выбранные посадочные не найдены: анализ не может честно оценить органику."
  });

  signals.push({
    id: "content_sources",
    level: output.pageScope.contentSources > 0 ? "ok" : "warning",
    title: "Контент-источники",
    message:
      output.pageScope.contentSources > 0
        ? `JSON/copy источников учтено: ${output.pageScope.contentSources}. Это помогает видеть смысл сайта даже при шаблонной сборке.`
        : "JSON/copy источники не найдены; если сайт собирается из CMS/JSON, часть смысла может быть потеряна."
  });

  if (output.pageScope.excludedDocuments > output.pageScope.indexablePages * 2) {
    signals.push({
      id: "technical_html_weight",
      level: "warning",
      title: "Много технического HTML",
      message: `Исключено HTML-документов: ${output.pageScope.excludedDocuments}. Это нормально для шаблонного сайта, но в отчёте важно не путать их с посадочными.`
    });
  }

  signals.push({
    id: "legacy",
    level: output.legacyFindings.length > 0 ? "problem" : "ok",
    title: "Legacy sanitation",
    message:
      output.legacyFindings.length > 0
        ? `Найдено legacy-упоминаний: ${output.legacyFindings.length}. Это блокер перед SEO-расширением.`
        : "Legacy-упоминаний из санитарного списка не найдено."
  });

  signals.push({
    id: "coverage",
    level: output.summary.coverageScore >= 70 ? "ok" : output.summary.coverageScore >= 45 ? "warning" : "problem",
    title: "Dev readiness",
    message: `Evidence-based readiness: ${output.summary.coverageScore}%. Lexical coverage: ${output.summary.lexicalCoverageScore}%, evidence: ${output.summary.evidenceScore}%, intent: ${output.summary.intentCoverageScore}%. Покрыто: ${output.summary.coveredClusters}, слабо: ${output.summary.weakClusters}, пусто: ${output.summary.missingClusters}.`
  });

  if (output.summary.lexicalCoverageScore - output.summary.coverageScore >= 20) {
    signals.push({
      id: "lexical_overestimation",
      level: "warning",
      title: "Lexical score завышает картину",
      message: `Слова совпадают на ${output.summary.lexicalCoverageScore}%, но доказательность только ${output.summary.coverageScore}%. Это значит, что часть кластеров задеты словарно, но слабо подтверждены посадочными, intent-сигналами или коммерческим контекстом.`
    });
  }

  signals.push({
    id: "commercial_readiness",
    level:
      output.summary.commercialReadinessScore >= 65 ? "ok" : output.summary.commercialReadinessScore >= 40 ? "warning" : "problem",
    title: "Коммерческая готовность",
    message: `Коммерческий deterministic score: ${output.summary.commercialReadinessScore}%. Учитываются нижняя воронка, интеграции, enterprise/security и CTA.`
  });

  const highPriorityThinClusters = output.clusters.filter(
    (cluster) => cluster.priority === "high" && cluster.evidence.level !== "strong"
  );

  if (highPriorityThinClusters.length > 0) {
    signals.push({
      id: "high_priority_evidence",
      level: "warning",
      title: "High-priority кластеры слабые",
      message: `Требуют усиления: ${highPriorityThinClusters
        .slice(0, 4)
        .map((cluster) => cluster.title)
        .join(", ")}. Без этого Wordstat/SERP-слой получит seed list, но не готовые SEO-посадочные.`
    });
  }

  signals.push({
    id: "style",
    level: output.styleProfile.topTerms.length > 0 ? "ok" : "warning",
    title: "Профиль стиля",
    message:
      output.styleProfile.topTerms.length > 0
        ? `Стиль считан: язык ${output.styleProfile.language}, средняя длина предложения ${output.styleProfile.averageSentenceWords} слов.`
        : "Стилевой профиль слабый: мало текста для сохранения тона при будущих правках."
  });

  return signals;
}

function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVerdict {
  const risks: AnalysisVerdict["risks"] = [
    {
      id: "local_only",
      level: "warning",
      title: "Это не рыночный прогноз",
      message:
        "Сейчас модуль оценивает только локальный текст, структуру и санитарные маркеры. Частотность, SERP, конкуренты, индекс и поведенческие данные не подключены."
    }
  ];

  if (output.legacyFindings.length > 0) {
    risks.push({
      id: "legacy_blocker",
      level: "problem",
      title: "Legacy-блокер",
      message: `Найдено legacy-упоминаний: ${output.legacyFindings.length}. До SEO-расширения их нужно убрать, иначе новый сайт смешивается со старой темой.`
    });
  }

  if (output.pageScope.selectedIndexablePages === 0) {
    risks.push({
      id: "no_indexable_pages",
      level: "problem",
      title: "Нет выбранных посадочных в покрытии",
      message: "Анализ не нашёл выбранные индексируемые страницы, поэтому не может честно оценить органическое покрытие сайта."
    });
  }

  if (output.pageScope.excludedDocuments > output.pageScope.indexablePages * 2) {
    risks.push({
      id: "technical_html_weight",
      level: "warning",
      title: "Много исключённого HTML",
      message:
        "В проекте много шаблонных или технических HTML-документов. Это не ошибка, но любые SEO-выводы должны строиться только по посадочным и смысловым JSON/copy-источникам."
    });
  }

  if (output.summary.lexicalCoverageScore - output.summary.coverageScore >= 20) {
    risks.push({
      id: "coverage_overestimation",
      level: "warning",
      title: "Lexical coverage завышает картину",
      message: `Лексическое покрытие ${output.summary.lexicalCoverageScore}%, но dev readiness ${output.summary.coverageScore}%. Значит часть тем задеты словами, но не доказаны посадочными, intent-сигналами или коммерческим контекстом.`
    });
  }

  if (output.summary.coveredClusters > 0) {
    risks.push({
      id: "market_validation_required",
      level: "warning",
      title: "Нужна проверка спроса",
      message:
        "Покрытые кластеры уже можно отдавать в Wordstat/SERP-слой как seed list, но пока нельзя ранжировать их по потенциальному трафику."
    });
  }

  const confidence =
    output.pageScope.selectedIndexablePages === 0 ||
    output.legacyFindings.length > 0 ||
    output.summary.qualityProblems > 1 ||
    output.summary.coverageScore < 45 ||
    output.summary.evidenceScore < 45
      ? "low"
      : output.summary.coverageScore >= 78 &&
          output.summary.evidenceScore >= 72 &&
          output.summary.intentCoverageScore >= 70 &&
          output.summary.qualityProblems === 0
        ? "high"
      : "medium";

  const summary =
    confidence === "low"
      ? `Dev readiness ${output.summary.coverageScore}%: локальная база пока слабая для SEO-расширения. Использовать как диагностику, не как прогноз выдачи.`
      : `Dev readiness ${output.summary.coverageScore}%: можно продолжать deterministic-доработку сайта и готовить seed list. Lexical ${output.summary.lexicalCoverageScore}%, evidence ${output.summary.evidenceScore}%, intent ${output.summary.intentCoverageScore}%; рынок пока не подтверждён Wordstat/SERP.`;

  return {
    confidence,
    marketObjectivity: "internal_only",
    summary,
    whatWorks: [
      `Scope разделён: выбранные посадочные ${output.pageScope.selectedIndexablePages}/${output.pageScope.indexablePages}, JSON/copy ${output.pageScope.contentSources}, исключённые HTML ${output.pageScope.excludedDocuments}.`,
      `Scoring v2 разделяет lexical coverage ${output.summary.lexicalCoverageScore}% и evidence-based readiness ${output.summary.coverageScore}%.`,
      `Intent-поддержка посчитана отдельно: ${output.summary.intentCoverageScore}%; коммерческий слой: ${output.summary.commercialReadinessScore}%.`,
      "Legacy sanitation проверяет старые темы и ссылки по всему снимку проекта, включая технические документы.",
      `Профиль стиля считан: язык ${output.styleProfile.language}, среднее предложение ${output.styleProfile.averageSentenceWords} слов.`
    ],
    limits: [
      "Нет частотности и сезонности Wordstat.",
      "Нет live SERP Яндекса, конкурентов, сниппетов и оценки сложности выдачи.",
      "Нет данных Yandex Webmaster об индексации, дублях, canonical и ошибках обхода.",
      "Нет Метрики/Search queries, поэтому нельзя связать семантику с фактическим трафиком и конверсией.",
      "Онтология NodeDC пока задана правилами внутри модуля, а не обучена на рынке."
    ],
    externalSignalsNeeded: [
      "Yandex Wordstat: частотность, регионы, похожие запросы, минус-слова.",
      "Yandex SERP collector: конкуренты, типы страниц, сниппеты, интент и сложность.",
      "Yandex Webmaster: индекс, ошибки обхода, статусы страниц, canonical, sitemap.",
      "Yandex Metrica/Search queries: реальные показы, клики, CTR, поведение и конверсии.",
      "Региональная и коммерческая разметка интентов: Москва/РФ, B2B, enterprise, 1C, AI automation."
    ],
    risks,
    clusterAssessments: output.clusters.map((cluster) => {
      if (cluster.status === "covered") {
        return {
          clusterId: cluster.id,
          title: cluster.title,
          assessment: "confirmed_in_content" as const,
          needsMarketValidation: true,
          reason: `Кластер подтверждён evidence v2: ${cluster.hits} hits, readiness ${cluster.score}%, lexical ${cluster.evidence.lexicalScore}%, intent ${cluster.evidence.intentScore}%, landing hits ${cluster.evidence.landingPageHits}. Термины: ${
            cluster.matchedTerms.slice(0, 5).join(", ") || "нет явных терминов"
          }.`
        };
      }

      if (cluster.status === "weak") {
        return {
          clusterId: cluster.id,
          title: cluster.title,
          assessment: "weak_in_content" as const,
          needsMarketValidation: true,
          reason: `Кластер задет, но не доказан: ${cluster.hits} hits, readiness ${cluster.score}%, evidence ${cluster.evidence.level}. Слабые места: ${cluster.evidence.penalties.join(", ") || cluster.evidence.missingIntentGroups.join(", ") || "недостаточно landing evidence"}.`
        };
      }

      return {
        clusterId: cluster.id,
        title: cluster.title,
        assessment: "missing_in_content" as const,
        needsMarketValidation: false,
        reason: "Кластер не подтверждён текущим контентом. Сначала нужна страница, секция или осмысленный copy source."
      };
    }),
    nextValidationStep:
      "Подключить Wordstat/SERP-слой, прогнать seed candidates по спросу и конкуренции, затем собрать keyword map с интентами и целевыми страницами."
  };
}

function buildNextActions(output: SemanticAnalysisQualityOutput) {
  const actions: string[] = [];

  if (output.legacyFindings.length > 0) {
    actions.push("Сначала убрать legacy-упоминания и старые ссылки: это санитарный блокер перед SEO-расширением.");
  }

  if (output.pageScope.indexablePages === 0) {
    actions.push("Добавить или выбрать индексируемые HTML-страницы: сейчас анализ видит только технический контент.");
  }

  if (output.pageScope.excludedDocuments > output.pageScope.indexablePages * 2) {
    actions.push("При сборе Wordstat и keyword map использовать scope=indexable_page/content_source; шаблоны и админку держать только как технический контекст.");
  }

  if (output.summary.coverageScore < 70) {
    actions.push("Поднять dev readiness: усилить слабые high-priority кластеры посадочными блоками, intent-сигналами и доказательными evidence snippets.");
  }

  if (output.summary.lexicalCoverageScore - output.summary.coverageScore >= 20) {
    actions.push("Не добавлять слова ради слов: lexical coverage выше readiness, поэтому нужны посадочные доказательства, коммерческий контекст и intent-поддержка.");
  }

  if (output.summary.commercialReadinessScore < 55) {
    actions.push("Усилить коммерческий слой: демо/пилот/стоимость/внедрение, интеграции, enterprise/security и CTA на индексируемых страницах.");
  }

  if (output.gaps.some((gap) => gap.priority === "high")) {
    actions.push("Для high-priority gaps подготовить отдельные посадочные страницы или секции на главной.");
  }

  const targetPagesToCreate = output.landingPagePlan.filter((page) => page.action === "create");

  if (targetPagesToCreate.length > 0) {
    actions.push(
      `Собрать landing page plan: ${targetPagesToCreate
        .slice(0, 4)
        .map((page) => `${page.path} → ${page.clusters[0]?.title ?? page.pageType}`)
        .join("; ")}.`
    );
  }

  if (output.landingPageBriefs.length > 0) {
    actions.push(
      `Подготовить первые SEO briefs к рерайту: ${output.landingPageBriefs
        .slice(0, 3)
        .map((brief) => brief.path)
        .join(", ")}.`
    );
  }

  actions.push("Передать seed candidates в Wordstat/Yandex layer и классифицировать фразы на use/support/article/risky/trash.");

  return actions;
}

function mapAnalysisRun(row: AnalysisRunRow): SemanticAnalysisRun | null {
  if (!row.output) {
    return null;
  }

  const output = hydrateLandingBriefQualityContracts(row.output);

  return {
    ...output,
    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()
  };
}

async function getLatestDoneScan(projectId: string) {
  const result = await pool.query<LatestScanRow>(
    `
      select
        sv.id,
        sv.project_id,
        sv.source_id,
        sv.project_index_key,
        sv.completed_at,
        sv.created_at,
        ps.type as source_type,
        ps.config as source_config
      from scan_versions sv
      join project_sources ps on ps.id = sv.source_id
      where sv.project_id = $1 and sv.status = 'done'
      order by sv.completed_at desc nulls last, sv.created_at desc
      limit 1;
    `,
    [projectId]
  );

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

async function getScanPages(scanVersionId: string) {
  const result = await pool.query<PageRow>(
    `
      select
        pv.page_id,
        p.selected,
        pv.source_path,
        pv.url_path,
        pv.title,
        pv.description,
        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]
  );

  return result.rows;
}

async function getScanDataAssets(scanVersionId: string) {
  const result = await pool.query<DataAssetRow>(
    `
      select source_path
      from media_versions
      where scan_version_id = $1
        and (media_type = 'data' or source_path ilike '%.json')
      order by source_path asc;
    `,
    [scanVersionId]
  );

  return result.rows;
}

async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisOutput | null> {
  const scan = await getLatestDoneScan(projectId);

  if (!scan) {
    return null;
  }

  const [pages, dataAssets] = await Promise.all([getScanPages(scan.id), getScanDataAssets(scan.id)]);
  const pageDocuments = await Promise.all(pages.map((page) => buildPageDocument(scan, page)));
  const contentDocuments = (
    await Promise.all(dataAssets.map((asset) => buildContentJsonDocument(scan, asset.source_path)))
  ).filter((document): document is ContentDocument => Boolean(document));
  const documents = [...pageDocuments, ...contentDocuments];
  const coverageDocuments = documents.filter((document) => document.usedForCoverage);
  const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
  const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents);
  const clusters = analyzeClusters(coverageDocuments);
  const coverageScore = getCoverageScore(clusters);
  const lexicalCoverageScore = getLexicalCoverageScore(clusters);
  const evidenceScore = getEvidenceScore(clusters);
  const intentCoverageScore = getIntentCoverageScore(clusters);
  const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile);
  const legacyFindings = analyzeLegacy(documents);
  const gaps = clusters
    .filter((cluster) => cluster.status !== "covered")
    .sort((first, second) => PRIORITY_WEIGHT[second.priority] - PRIORITY_WEIGHT[first.priority] || first.score - second.score)
    .map((cluster) => ({
      clusterId: cluster.id,
      title: cluster.title,
      status: cluster.status as "weak" | "missing",
      priority: cluster.priority,
      targetIntent: cluster.targetIntent,
      targetPages: cluster.targetPages,
      seedExamples: cluster.queryExamples
    }));
  const seedCandidates = buildSeedCandidates(clusters);
  const landingOpportunities = buildLandingOpportunities(clusters, coverageDocuments);
  const landingPagePlan = buildLandingPagePlan(landingOpportunities, clusters);
  const landingPageBriefs = buildLandingPageBriefs(landingPagePlan);
  const pageClusterMap = new Map(
    pageDocuments.map((document) => {
      const matchedClusters = clusters
        .filter((cluster) => cluster.sourceRefs.some((ref) => ref.id === document.id))
        .map((cluster) => cluster.id);

      return [document.id, matchedClusters];
    })
  );
  const contentClusterMap = new Map(
    contentDocuments.map((document) => {
      const matchedClusters = clusters
        .filter((cluster) => cluster.sourceRefs.some((ref) => ref.id === document.id))
        .map((cluster) => cluster.id);

      return [document.id, matchedClusters];
    })
  );
  const outputBase = {
    schemaVersion: "semantic-analysis.v1",
    projectId,
    scanVersionId: scan.id,
    sourceId: scan.source_id,
    projectIndexKey: scan.project_index_key,
    generatedAt: new Date().toISOString(),
    summary: {
      coverageScore,
      lexicalCoverageScore,
      evidenceScore,
      intentCoverageScore,
      commercialReadinessScore,
      coveredClusters: clusters.filter((cluster) => cluster.status === "covered").length,
      weakClusters: clusters.filter((cluster) => cluster.status === "weak").length,
      missingClusters: clusters.filter((cluster) => cluster.status === "missing").length,
      legacyFindings: legacyFindings.length,
      pagesAnalyzed: pageDocuments.length,
      selectedPagesAnalyzed: pageDocuments.filter((document) => document.selected).length,
      contentSourcesAnalyzed: contentDocuments.length,
      indexablePagesAnalyzed: pageScope.selectedIndexablePages,
      excludedHtmlDocuments: pageScope.excludedDocuments,
      qualityProblems: 0,
      qualityWarnings: 0,
      seedCandidates: seedCandidates.length
    },
    pageScope,
    styleProfile,
    clusters,
    pages: pages.map((page) => {
      const document = pageDocuments.find((item) => item.id === page.page_id);
      const fallbackScope = classifyPageScope(page);

      return {
        pageId: page.page_id,
        selected: page.selected,
        title: document?.title ?? page.title ?? page.source_path,
        sourcePath: page.source_path,
        urlPath: page.url_path,
        scope: document?.scope === "content_source" ? fallbackScope.scope : document?.scope ?? fallbackScope.scope,
        scopeReason: document?.scopeReason ?? fallbackScope.reason,
        usedForCoverage: document?.usedForCoverage ?? false,
        wordCount: document?.wordCount ?? 0,
        matchedClusters: pageClusterMap.get(page.page_id) ?? [],
        missingCriticalFields: getPageMissingCriticalFields(page)
      };
    }),
    contentSources: contentDocuments.map((document) => ({
      id: document.id,
      sourcePath: document.sourcePath,
      title: document.title,
      usedForCoverage: document.usedForCoverage,
      wordCount: document.wordCount,
      matchedClusters: contentClusterMap.get(document.id) ?? []
    })),
    legacyFindings,
    gaps,
    landingOpportunities,
    landingPagePlan,
    landingPageBriefs,
    seedCandidates
  } satisfies SemanticAnalysisBaseOutput;
  const qualitySignals = buildQualitySignals(outputBase);
  const outputWithQuality = {
    ...outputBase,
    summary: {
      ...outputBase.summary,
      qualityProblems: qualitySignals.filter((signal) => signal.level === "problem").length,
      qualityWarnings: qualitySignals.filter((signal) => signal.level === "warning").length
    },
    qualitySignals
  } satisfies SemanticAnalysisQualityOutput;
  const analysisVerdict = buildAnalysisVerdict(outputWithQuality);

  return {
    ...outputWithQuality,
    analysisVerdict,
    nextActions: buildNextActions(outputWithQuality)
  };
}

export async function runSemanticAnalysis(projectId: string) {
  const output = await buildAnalysisOutput(projectId);

  if (!output) {
    return null;
  }

  const result = await pool.query<AnalysisRunRow>(
    `
      insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
      values ($1, 'semantic_analysis', 'done', $2, $3, now(), now())
      returning id, status, output, error_message, started_at, completed_at, created_at;
    `,
    [
      projectId,
      {
        scanVersionId: output.scanVersionId,
        schemaVersion: output.schemaVersion
      },
      output
    ]
  );

  const row = result.rows[0];

  if (!row) {
    throw new SemanticAnalysisError("Не удалось сохранить semantic analysis run.");
  }

  return mapAnalysisRun(row);
}

export async function getLatestSemanticAnalysis(projectId: string) {
  const result = await pool.query<AnalysisRunRow>(
    `
      select id, status, output, error_message, started_at, completed_at, created_at
      from runs
      where project_id = $1 and run_type = 'semantic_analysis'
      order by created_at desc
      limit 1;
    `,
    [projectId]
  );

  const row = result.rows[0];

  return row ? mapAnalysisRun(row) : null;
}

function csvCell(value: unknown) {
  const text = Array.isArray(value) ? value.join("; ") : String(value ?? "");
  return `"${text.replace(/"/g, "\"\"")}"`;
}

function buildSemanticCsvExport(analysis: SemanticAnalysisRun) {
  const rows = [
    [
      "path",
      "action",
      "pageType",
      "priority",
      "h1",
      "title",
      "metaDescription",
      "briefQualityScore",
      "briefQualityLevel",
      "primaryCta",
      "secondaryCta",
      "clusters",
      "seedPhrases",
      "internalLinks",
      "checklistDone",
      "checklistPending",
      "qualityWarnings",
      "qualityProblems",
      "missingEvidence"
    ],
    ...analysis.landingPageBriefs.map((brief) => [
      brief.path,
      brief.action,
      brief.pageType,
      brief.priority,
      brief.h1,
      brief.title,
      brief.metaDescription,
      brief.qualityContract.score,
      brief.qualityContract.level,
      brief.primaryCta,
      brief.secondaryCta,
      brief.clusters.map((cluster) => `${cluster.title} (${cluster.status})`),
      brief.seedPhrases,
      brief.internalLinks,
      brief.readinessChecklist.filter((item) => item.done).map((item) => item.title),
      brief.readinessChecklist.filter((item) => !item.done).map((item) => item.title),
      brief.qualityContract.riskFlags.filter((risk) => risk.level === "warning").map((risk) => risk.title),
      brief.qualityContract.riskFlags.filter((risk) => risk.level === "problem").map((risk) => risk.title),
      brief.qualityContract.missingEvidence
    ])
  ];

  return `\uFEFF${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`;
}

function markdownList(items: string[]) {
  if (items.length === 0) {
    return "- нет данных";
  }

  return items.map((item) => `- ${item}`).join("\n");
}

function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) {
  const lines: string[] = [
    `# NodeDC SEO export`,
    "",
    `Run: ${analysis.runId}`,
    `Generated: ${analysis.generatedAt}`,
    `Dev readiness: ${analysis.summary.coverageScore}%`,
    `Evidence: ${analysis.summary.evidenceScore}%`,
    `Intent: ${analysis.summary.intentCoverageScore}%`,
    `Commercial readiness: ${analysis.summary.commercialReadinessScore}%`,
    `Landing page plan: ${analysis.landingPagePlan.length}`,
    `SEO briefs: ${analysis.landingPageBriefs.length}`,
    "",
    "## Landing page plan",
    ""
  ];

  for (const page of analysis.landingPagePlan.slice(0, 12)) {
    lines.push(
      `### ${page.path}`,
      "",
      `Action: ${page.action}`,
      `Type: ${page.pageType}`,
      `Priority: ${page.priority}`,
      `Opportunity: ${page.opportunityScore}%`,
      `Readiness: ${page.readinessScore}%`,
      "",
      page.reason,
      "",
      "Clusters:",
      markdownList(page.clusters.map((cluster) => `${cluster.title} (${cluster.status}, ${cluster.evidenceLevel})`)),
      "",
      "Seed phrases:",
      markdownList(page.seedPhrases.slice(0, 6)),
      ""
    );
  }

  lines.push("## SEO briefs", "");

  for (const brief of analysis.landingPageBriefs.slice(0, 12)) {
    lines.push(
      `### ${brief.path}`,
      "",
      `H1: ${brief.h1}`,
      `Title: ${brief.title}`,
      `Meta: ${brief.metaDescription}`,
      `Quality: ${brief.qualityContract.score}% (${brief.qualityContract.level})`,
      `Quality summary: ${brief.qualityContract.summary}`,
      `Audience: ${brief.audience}`,
      `Primary CTA: ${brief.primaryCta}`,
      `Secondary CTA: ${brief.secondaryCta}`,
      "",
      "Intent:",
      brief.targetIntent,
      "",
      "Intro angle:",
      brief.introAngle,
      "",
      "Must-use terms:",
      markdownList(brief.mustUseTerms),
      "",
      "Internal links:",
      markdownList(brief.internalLinks),
      "",
      "Sections:"
    );

    for (const section of brief.sections) {
      lines.push(`- ${section.title}: ${section.goal}`);
    }

    lines.push("", "Checklist:");

    for (const item of brief.readinessChecklist) {
      lines.push(`- [${item.done ? "x" : " "}] ${item.title}: ${item.reason}`);
    }

    lines.push("", "Quality checks:");

    for (const check of brief.qualityContract.checks) {
      lines.push(`- ${check.status}: ${check.title} — ${check.reason}`);
    }

    if (brief.qualityContract.riskFlags.length > 0) {
      lines.push("", "Risk flags:");
      for (const risk of brief.qualityContract.riskFlags) {
        lines.push(`- ${risk.level}: ${risk.title} — ${risk.message}`);
      }
    }

    lines.push("", "Missing evidence:", markdownList(brief.qualityContract.missingEvidence));

    lines.push("");
  }

  return `${lines.join("\n")}\n`;
}

function buildSemanticJsonExport(analysis: SemanticAnalysisRun) {
  return `${JSON.stringify(
    {
      exportVersion: "semantic-export.v1",
      exportedAt: new Date().toISOString(),
      analysis
    },
    null,
    2
  )}\n`;
}

export async function getSemanticAnalysisExport(projectId: string, format: SemanticAnalysisExportFormat) {
  const analysis = await getLatestSemanticAnalysis(projectId);

  if (!analysis) {
    return null;
  }

  const fileBaseName = `nodedc-seo-${analysis.runId.slice(0, 8)}`;

  if (format === "csv") {
    return {
      body: buildSemanticCsvExport(analysis),
      contentType: "text/csv; charset=utf-8",
      filename: `${fileBaseName}.csv`
    };
  }

  if (format === "markdown") {
    return {
      body: buildSemanticMarkdownExport(analysis),
      contentType: "text/markdown; charset=utf-8",
      filename: `${fileBaseName}.md`
    };
  }

  return {
    body: buildSemanticJsonExport(analysis),
    contentType: "application/json; charset=utf-8",
    filename: `${fileBaseName}.json`
  };
}
