import type { Graph } from '../model/graph.js';
import type { ValidationIssue } from '../model/validation.js';
import { DEFAULT_COVERAGE } from '../io/config-parser.js';
import { normalizeMappingPaths } from '../io/paths.js';
import { validate } from './validator.js';
import { readTextFile } from '../io/graph-fs.js';
import path from 'node:path';
import { validateAppendOnly } from './log-integrity.js';
import { STRUCTURAL_CODES, COMPLETENESS_CODES } from './check-codes.js';
import { validateFormat } from './log-format.js';
import { toPosixPath } from '../utils/posix.js';
import { excludeNestedGraphSubtrees, loadRootGitignoreStack, isIgnoredByStack } from '../io/repo-scanner.js';
import type { GitignoreEntry } from '../io/repo-scanner.js';
import { mappingEntryMatchesFile, normalizeMappingPath, isGlobPattern } from '../utils/mapping-path.js';
import { debugWrite } from '../utils/debug-log.js';
// ── Verdict-lock live path (spec §6) ──────────────────────────
import { readLock, LockInvalidError } from '../io/lock-store.js';
import { verifyLock } from './verify-lock.js';
import type { VerifiedPair } from './verify-lock.js';
import {
  unverifiedMessage,
  llmRefusedMessage,
  detRefusedMessage,
  promptTooLargeMessage,
} from '../formatters/lock-issue-messages.js';

// ── Types ──────────────────────────────────────────────────

export interface CheckIssue extends Omit<ValidationIssue, 'code'> {
  /** All issues have a code -- override optional from ValidationIssue */
  code: string;
  /** For unmapped-files: uncovered file paths */
  uncoveredFiles?: string[];
  /** For unmapped-files: total count of uncovered files */
  uncoveredCount?: number;
  /** For aspect-newly-active / aspect-violation-*: the aspect this issue concerns */
  aspectId?: string;
}

export interface CheckResult {
  projectName: string;
  nodeCount: number;
  nodeTypeCounts: Map<string, number>;
  aspectCount: number;
  flowCount: number;
  coveredFiles: number;
  totalFiles: number;
  issues: CheckIssue[];
  /** Suggested next command based on highest-priority error */
  suggestedNext: string | null;
  /** Count of aspect-violation-advisory warnings (subset of issues). Surfaced as a footer tally. */
  advisoryWarnings: number;
  /** Count of (node, aspect) pairs where the aspect resolves to effective status 'draft'. */
  draftSkipped: number;
}

// ── Lock verification → issue emission (live path, spec §6) ──

/** Fallback text when a refused verdict carries no stored reason. Single source of truth. */
const NO_REASON_FALLBACK = 'no violation details recorded';

/**
 * Turn a VerifiedPair into zero, one, or two CheckIssues (spec §6/§10):
 *   - verified            → no issue.
 *   - refused (enforced)  → aspect-violation-enforced (error).
 *   - refused (advisory)  → aspect-violation-advisory (warning).
 *   - unverified          → unverified (error if enforced, warning if advisory).
 *   - prompt-too-large    → prompt-too-large (error); REPLACES unverified (gate
 *                           precedence) — no duplicate unverified is emitted.
 *   - valid + oversized   → the verdict issue PLUS a prompt-too-large error
 *                           (the verdict still renders; the gate also surfaces).
 *
 * Severity follows the pair's EFFECTIVE status, recomputed live in pair.status.
 */
function emitPairIssue(vp: VerifiedPair): CheckIssue[] {
  const { pair, state } = vp;
  const issues: CheckIssue[] = [];
  const enforced = pair.status === 'enforced';

  switch (state.kind) {
    case 'verified':
      break;
    case 'refused': {
      const reason = state.reason ?? NO_REASON_FALLBACK;
      const md =
        pair.kind === 'llm'
          ? llmRefusedMessage({ aspectId: pair.aspectId, unitKey: pair.unitKey, reason })
          : detRefusedMessage({ aspectId: pair.aspectId, unitKey: pair.unitKey, reason });
      issues.push({
        severity: enforced ? 'error' : 'warning',
        code: enforced ? 'aspect-violation-enforced' : 'aspect-violation-advisory',
        rule: enforced ? 'aspect-violation-enforced' : 'aspect-violation-advisory',
        messageData: md,
        nodePath: pair.nodePath,
        aspectId: pair.aspectId,
      });
      break;
    }
    case 'unverified':
      issues.push({
        severity: enforced ? 'error' : 'warning',
        code: 'unverified',
        rule: 'unverified',
        messageData: unverifiedMessage({ aspectId: pair.aspectId, unitKey: pair.unitKey }),
        nodePath: pair.nodePath,
        aspectId: pair.aspectId,
      });
      break;
    case 'prompt-too-large':
      issues.push({
        severity: 'error',
        code: 'prompt-too-large',
        rule: 'prompt-too-large',
        messageData: promptTooLargeMessage({
          aspectId: pair.aspectId,
          unitKey: pair.unitKey,
          tierName: state.tierName,
          chars: state.chars,
          limit: state.limit,
        }),
        nodePath: pair.nodePath,
        aspectId: pair.aspectId,
      });
      break;
  }

  // Valid-but-oversized: the verdict issue (if any) was already pushed above;
  // additionally surface the gate error so size remedies reach the agent.
  if (vp.oversized) {
    issues.push({
      severity: 'error',
      code: 'prompt-too-large',
      rule: 'prompt-too-large',
      messageData: promptTooLargeMessage({
        aspectId: pair.aspectId,
        unitKey: pair.unitKey,
        tierName: vp.oversized.tierName,
        chars: vp.oversized.chars,
        limit: vp.oversized.limit,
      }),
      nodePath: pair.nodePath,
      aspectId: pair.aspectId,
    });
  }

  return issues;
}

/**
 * Log integrity + format for ALL nodes, reading the append-only baseline from
 * the LOCK (`lock.nodes[path].log`) instead of per-node drift state (spec §9).
 * `validateAppendOnly` / `validateFormat` logic is unchanged. Restore strings
 * reference `.yggdrasil/yg-lock.json`.
 */
async function classifyLogStateFromLock(
  graph: Graph,
  projectRoot: string,
  lock: LockFileForCheck,
  issues: CheckIssue[],
): Promise<void> {
  for (const [nodePath] of graph.nodes) {
    const logRel = `.yggdrasil/model/${nodePath}/log.md`;
    const logAbs = path.join(projectRoot, logRel);
    let logContent: string | null = null;
    try {
      logContent = await readTextFile(logAbs);
    } catch { /* missing — keep null */ }

    const logBaseline = lock.nodes[nodePath]?.log;

    if (logBaseline) {
      const check = validateAppendOnly(
        logContent ?? '',
        logBaseline.last_entry_datetime,
        logBaseline.prefix_hash,
      );
      if (!check.ok) {
        const logIntegrityMd = {
          what: `Log integrity broken (${check.reason}) at ${logRel}${logContent === null ? ' (file missing)' : ''}`,
          why: check.reason === 'prefix_modified'
            ? 'Historical (pre-baseline) log content was modified — append-only violated.'
            : 'Baseline boundary entry not found — log was deleted or reset.',
          next: `Restore from git: git checkout HEAD -- ${logRel} .yggdrasil/yg-lock.json`,
        };
        issues.push({
          severity: 'error',
          code: 'log-integrity',
          rule: 'log-integrity',
          messageData: logIntegrityMd,
          nodePath,
        });
        continue;
      }
    }

    if (logContent === null) continue;

    const violations = validateFormat(logContent);
    if (violations.length > 0) {
      const logFormatMd = {
        what: `Log format invalid at ${logRel}:\n${violations.map((v) => `  line ${v.line}: ${v.reason} — ${v.detail}`).join('\n')}`,
        why: 'Log format must be parseable for indexing and integrity.',
        next: 'Fix format violations (or git checkout) and re-run yg check.',
      };
      issues.push({
        severity: 'error',
        code: 'log-format',
        rule: 'log-format',
        messageData: logFormatMd,
        nodePath,
      });
    }
  }
}

/** Minimal shape of the lock needed by the check live path. */
interface LockFileForCheck {
  nodes: Record<string, { log?: { last_entry_datetime: string; prefix_hash: string } }>;
}

// ── Coverage scan (unmapped-files) ────────────────────────

export {
  normalizeRoot,
  matchesRoot,
  partitionByCoverageTier,
  buildCoverageIssue,
  buildCoverageAdvisoryIssue,
} from './check-coverage-tiers.js';
import { partitionByCoverageTier, buildCoverageIssue, buildCoverageAdvisoryIssue } from './check-coverage-tiers.js';

/**
 * Find git-tracked files not covered by any node mapping.
 * Accepts gitTrackedFiles as parameter for testability (CLI layer calls `git ls-files`).
 * Excludes files under the bound graph's own .yggdrasil/ and under any nested-graph
 * subtree (a directory that contains its own .yggdrasil/).
 */
export function scanUncoveredFiles(graph: Graph, gitTrackedFiles: string[]): string[] {
  // Build list of all mapping paths (normalized)
  const allMappings: string[] = [];
  for (const node of graph.nodes.values()) {
    const paths = normalizeMappingPaths(node.meta.mapping);
    allMappings.push(...paths);
  }

  // Determine .yggdrasil prefix relative to project root
  const projectRoot = path.dirname(graph.rootPath);
  const yggPrefix = toPosixPath(path.relative(projectRoot, graph.rootPath));

  const uncovered: string[] = [];

  const tracked = excludeNestedGraphSubtrees(gitTrackedFiles);
  for (const file of tracked) {
    const normalized = toPosixPath(file.trim());

    // Exclude .yggdrasil/ files
    if (normalized.startsWith(yggPrefix + '/') || normalized === yggPrefix) continue;

    // Check if covered by any mapping
    const covered = allMappings.some((mp) => mappingEntryMatchesFile(mp, normalized));

    if (!covered) {
      uncovered.push(normalized);
    }
  }

  return uncovered.sort();
}

/**
 * Detect git-tracked files that the coverage scan counts as "covered" but that
 * are silently dropped from every node's expanded subject set — a false-green.
 *
 * `expandMappingPaths` gitignore-filters the results of a DIRECTORY/GLOB mapping
 * entry (a `.gitignore`-matched file is skipped), while a DIRECTLY-NAMED
 * single-file mapping entry bypasses gitignore and is always hashed. So a file
 * that is BOTH git-tracked AND gitignored (legal: `git add -f`, or a `.gitignore`
 * rule added after the file was tracked) and is reached ONLY through a
 * directory/glob entry is claimed as covered yet produces no review pair — an
 * enforced rule passes over it without any reviewer seeing it.
 *
 * A file is a "silent drop" when ALL FOUR hold:
 *   (1) it is git-tracked (in `gitTrackedFiles`), AND
 *   (2) it is matched by at least one node's mapping entry (treated as covered), AND
 *   (3) it is gitignored (root `.gitignore`, the same machinery the hash layer uses), AND
 *   (4) it is NOT matched by any DIRECTLY-NAMED single-file mapping entry anywhere in
 *       the graph (a directly-named entry bypasses gitignore and would include it → safe).
 *
 * A mapping entry is "directly-named" for a file when it is a concrete path with no
 * glob characters whose normalized form EQUALS the normalized file path. A directory
 * entry (prefix match) or a glob entry is NOT directly-named.
 *
 * Returns the offending repo-relative POSIX paths, sorted. This is PURELY ADDITIVE:
 * it does not touch `scanUncoveredFiles` or the mapping-expansion logic.
 */
export async function scanGitignoredCoveredFiles(
  graph: Graph,
  gitTrackedFiles: string[],
): Promise<string[]> {
  // Collect all mapping entries, and separately the set of directly-named (plain,
  // non-glob) entries — the latter bypass gitignore in the hash layer.
  const allMappings: string[] = [];
  const directlyNamed = new Set<string>();
  for (const node of graph.nodes.values()) {
    for (const raw of normalizeMappingPaths(node.meta.mapping)) {
      allMappings.push(raw);
      if (!isGlobPattern(raw)) directlyNamed.add(normalizeMappingPath(raw));
    }
  }

  const projectRoot = path.dirname(graph.rootPath);
  const yggPrefix = toPosixPath(path.relative(projectRoot, graph.rootPath));

  // Load the root .gitignore stack once (the same loader the hash/expand layer uses).
  // A failure to read it is debug-logged inside the loader and yields an empty stack —
  // no false positives (nothing is reported as gitignored).
  let gitignoreStack: GitignoreEntry[];
  try {
    gitignoreStack = await loadRootGitignoreStack(projectRoot);
  } catch (err) {
    debugWrite(`[check] scanGitignoredCoveredFiles: gitignore load failed: ${(err as Error).message}`);
    gitignoreStack = [];
  }
  if (gitignoreStack.length === 0) return [];

  const offending: string[] = [];
  const tracked = excludeNestedGraphSubtrees(gitTrackedFiles);
  for (const file of tracked) {
    const normalized = toPosixPath(file.trim());

    // (graph-self exclusion, mirrors scanUncoveredFiles)
    if (normalized.startsWith(yggPrefix + '/') || normalized === yggPrefix) continue;

    // (2) matched by at least one node's mapping entry → counted as covered.
    if (!allMappings.some((mp) => mappingEntryMatchesFile(mp, normalized))) continue;

    // (4) a directly-named single-file entry pointing at this exact file rescues it.
    if (directlyNamed.has(normalizeMappingPath(normalized))) continue;

    // (3) gitignored under the root .gitignore (absolute path, like the hash layer).
    let ignored: boolean;
    try {
      ignored = isIgnoredByStack(path.join(projectRoot, normalized), gitignoreStack);
    } catch (err) {
      debugWrite(`[check] scanGitignoredCoveredFiles: isIgnoredByStack threw for ${normalized}: ${(err as Error).message}`);
      continue;
    }
    if (!ignored) continue;

    offending.push(normalized);
  }

  return offending.sort();
}

/**
 * Build one blocking 'mapped-file-gitignored' CheckIssue per silent-drop file.
 * Distinct what/why/next from the plain "not covered" wording — the file IS
 * matched by a mapping; the problem is the gitignore conflict that excludes it
 * from review. Structured messageData only (no buildIssueMessage in the engine —
 * the CLI layer renders it, exactly like every other coverage/structural issue).
 */
function buildGitignoredCoveredIssues(offending: string[]): CheckIssue[] {
  return offending.map((file) => ({
    severity: 'error' as const,
    code: 'mapped-file-gitignored',
    rule: 'mapped-file-gitignored',
    messageData: {
      what: `File '${file}' is git-tracked and matched by a node mapping, but is excluded from review because it matches a .gitignore pattern (and is only reached via a directory/glob mapping entry).`,
      why: 'A directory/glob mapping entry skips gitignored files, so this tracked source file produces no review subject — an enforced rule would pass over it without any reviewer seeing it (a false green).',
      next: `Un-ignore the file in .gitignore, name it directly in the node mapping (direct file entries bypass gitignore), or stop tracking it (git rm --cached ${file}), then re-run yg check.`,
    },
  }));
}

// ── Check orchestrator ────────────────────────────────────

/**
 * Run the full check — a PURE READ (spec §6):
 *   structural validation → coverage → prompt-size gate → lock verification →
 *   log integrity → report.
 *
 * No drift classification, no execution stage, no writes. The lock is the only
 * persisted verification state; pairs are validated by hashing.
 *
 * @param gitTrackedFiles -- pass null to skip unmapped-files check (no git available).
 */
export async function runCheck(graph: Graph, gitTrackedFiles: string[] | null): Promise<CheckResult> {
  const projectRoot = path.dirname(graph.rootPath);

  // 1. Validation (structural + completeness)
  const validation = await validate(graph);
  // Filter out issues without a code -- they are internal (e.g., invalid-scope).
  const validationIssues: CheckIssue[] = validation.issues
    .filter(vi => vi.code)
    .map(vi => ({ ...vi, code: vi.code! }));

  // 2. Lock verification (replaces drift classification). Read the lock once.
  // A garbled/version/conflict-markered lock fails closed: emit one blocking
  // lock-invalid issue and SKIP lock verification + log integrity (the baseline
  // home is untrustworthy). The prompt-size gate is folded into verifyLock.
  const lockIssues: CheckIssue[] = [];
  try {
    const lock = readLock(graph.rootPath);
    const verification = await verifyLock(graph, lock);

    // Unreadable subjects → blocking file-unreadable errors (A4 fail-closed).
    // messageData is pre-populated on UnreadableSubject by computeExpectedPairs.
    for (const u of verification.unreadable) {
      lockIssues.push({
        severity: 'error',
        code: 'file-unreadable',
        rule: 'file-unreadable',
        messageData: u.messageData,
        nodePath: u.nodePath,
        aspectId: u.aspectId,
      });
    }

    // Per-pair issues (verified → none; refused / unverified / prompt-too-large).
    for (const vp of verification.pairs) {
      lockIssues.push(...emitPairIssue(vp));
    }

    // Log integrity reads its baseline from the lock (spec §9).
    await classifyLogStateFromLock(graph, projectRoot, lock, lockIssues);
  } catch (err) {
    if (err instanceof LockInvalidError) {
      lockIssues.push({
        severity: 'error',
        code: 'lock-invalid',
        rule: 'lock-invalid',
        messageData: err.messageData,
      });
      // Fail closed: skip lock verification + log integrity.
    } else {
      throw err;
    }
  }

  // 3. Coverage scan (unmapped-files / uncovered-advisory) — unchanged.
  let coverageIssues: CheckIssue[] = [];
  let coveredFiles = 0;
  let totalFiles = 0;
  if (gitTrackedFiles !== null) {
    const yggPrefix = toPosixPath(path.relative(projectRoot, graph.rootPath));
    const sourceFiles = excludeNestedGraphSubtrees(gitTrackedFiles).filter(f => {
      const normalized = toPosixPath(f.trim());
      return !normalized.startsWith(yggPrefix + '/') && normalized !== yggPrefix;
    });
    totalFiles = sourceFiles.length;
    const uncovered = scanUncoveredFiles(graph, gitTrackedFiles);
    const coverage = graph.config.coverage ?? DEFAULT_COVERAGE;
    const tiers = partitionByCoverageTier(uncovered, coverage);
    coveredFiles = totalFiles - (tiers.required.length + tiers.middle.length);
    coverageIssues = [
      buildCoverageIssue(tiers.required, totalFiles),
      buildCoverageAdvisoryIssue(tiers.middle),
    ].filter((x): x is CheckIssue => x !== null);

    // Additive false-green detection: files counted as covered above but silently
    // dropped from every node's subject set because they are gitignored and reached
    // only through a directory/glob mapping entry. Blocking (mapped-file-gitignored).
    const gitignoredCovered = await scanGitignoredCoveredFiles(graph, gitTrackedFiles);
    coverageIssues.push(...buildGitignoredCoveredIssues(gitignoredCovered));
  }

  // Combine all issues
  const allIssues: CheckIssue[] = [
    ...lockIssues,
    ...validationIssues,
    ...coverageIssues,
  ];

  // Node type counts
  const nodeTypeCounts = new Map<string, number>();
  for (const node of graph.nodes.values()) {
    const t = node.meta.type;
    nodeTypeCounts.set(t, (nodeTypeCounts.get(t) ?? 0) + 1);
  }

  const suggestedNext = computeSuggestedNext(allIssues);
  const advisoryWarnings = allIssues.filter(i => i.code === 'aspect-violation-advisory').length;
  const draftSkipped = countDraftAspectsAcrossGraph(graph);

  return {
    projectName: path.basename(projectRoot),
    nodeCount: graph.nodes.size,
    nodeTypeCounts,
    aspectCount: graph.aspects.length,
    flowCount: graph.flows.length,
    coveredFiles,
    totalFiles,
    issues: allIssues,
    suggestedNext,
    advisoryWarnings,
    draftSkipped,
  };
}

// ── Internal helpers ───────────────────────────────────────

/**
 * Count UNIQUE aspect IDs whose aspect-level default status is 'draft'.
 * (Not the count of node×aspect pairs — aspects that are draft on some nodes
 * and non-draft on others are still counted once here.)
 * Surfaced as a header tally in `yg check` so the agent sees how many
 * dormant rules sit in the graph.
 */
function countDraftAspectsAcrossGraph(graph: Graph): number {
  let n = 0;
  for (const aspect of graph.aspects) {
    if ((aspect.status ?? 'enforced') === 'draft') n++;
  }
  return n;
}

/**
 * Suggest the next command based on the highest-priority error, in the §6 order:
 *   lock-invalid → unverified(enforced) → enforced refusal (three exits / fix
 *   violations, carried per-issue) → prompt-too-large → log integrity/format →
 *   mapped-file-gitignored → structural → coverage → completeness.
 *
 * Each lock issue carries its own kind-appropriate `next` in messageData
 * (cached three-exit for an LLM refusal, fix-violations for a deterministic
 * refusal, size remedies for prompt-too-large). When no error remains, surface
 * an advisory aspect-violation warning's `next` so a warnings-only run still
 * points somewhere.
 */
function computeSuggestedNext(issues: CheckIssue[]): string | null {
  const errors = issues.filter(i => i.severity === 'error');
  const ASPECT_WARNING_CODES = new Set(['aspect-violation-advisory']);
  if (errors.length === 0) {
    const firstAspectWarning = issues.find(i =>
      i.severity === 'warning' && ASPECT_WARNING_CODES.has(i.code),
    );
    return firstAspectWarning?.messageData.next ?? null;
  }

  // 1. lock-invalid — fail closed; restore-or-refill (its own next).
  const lockInvalid = errors.find(i => i.code === 'lock-invalid');
  if (lockInvalid) return lockInvalid.messageData.next;

  // 2. unverified (enforced) — fill the lock.
  const unverified = errors.find(i => i.code === 'unverified');
  if (unverified) return unverified.messageData.next;

  // 3. enforced refusal (LLM three-exit OR deterministic fix-violations — the
  //    correct text is already in each issue's messageData.next).
  const enforcedRefusal = errors.find(i => i.code === 'aspect-violation-enforced');
  if (enforcedRefusal) return enforcedRefusal.messageData.next;

  // 4. prompt-too-large — size remedies.
  const promptTooLarge = errors.find(i => i.code === 'prompt-too-large');
  if (promptTooLarge) return promptTooLarge.messageData.next;

  // 5. log integrity / format.
  const logIntegrity = errors.find(i => i.code === 'log-integrity');
  if (logIntegrity) {
    const node = logIntegrity.nodePath ?? '<unknown>';
    const count = errors.filter(i => i.code === 'log-integrity').length;
    return `git checkout HEAD -- .yggdrasil/model/${node}/log.md .yggdrasil/yg-lock.json\n  ${count} log integrity violation${count === 1 ? '' : 's'} — restore from git`;
  }
  const logFormat = errors.find(i => i.code === 'log-format');
  if (logFormat) {
    const node = logFormat.nodePath ?? '<unknown>';
    const count = errors.filter(i => i.code === 'log-format').length;
    return `Edit .yggdrasil/model/${node}/log.md to fix format violations\n  ${count} log format violation${count === 1 ? '' : 's'} — post-baseline edit OR git checkout for pre-baseline`;
  }

  // 6. mapped-file-gitignored — a false-green coverage conflict. It lives in
  //    STRUCTURAL_CODES (renders as a blocking error block), but its own next
  //    carries the file-specific remedy, so surface that directly rather than the
  //    generic structural "Fix <code>" line.
  const gitignoredCovered = errors.find(i => i.code === 'mapped-file-gitignored');
  if (gitignoredCovered) return gitignoredCovered.messageData.next;

  // 7. structural.
  const structuralErrors = errors.filter(i => STRUCTURAL_CODES.has(i.code));
  const coverageErrors = errors.filter(i => i.code === 'unmapped-files');
  if (structuralErrors.length > 0) {
    const first = structuralErrors[0];
    const then = coverageErrors.length > 0
      ? `\n  Then: ${coverageErrors[0].uncoveredCount ?? 0} files need coverage`
      : '';
    return `Fix ${first.code} in ${first.nodePath ?? '.yggdrasil'}\n  1 of ${structuralErrors.length} structural error${structuralErrors.length === 1 ? '' : 's'}${then}`;
  }

  // 8. coverage.
  if (coverageErrors.length > 0) {
    const count = coverageErrors[0].uncoveredCount ?? 0;
    return `yg context --file <uncovered-path>\n  ${count} file${count === 1 ? '' : 's'} need coverage — bootstrap workflow`;
  }

  // 9. completeness.
  const completenessErrors = errors.filter(i => COMPLETENESS_CODES.has(i.code));
  if (completenessErrors.length > 0) {
    const first = completenessErrors[0];
    return `Fix ${first.code} for ${first.nodePath}\n  1 of ${completenessErrors.length} completeness error${completenessErrors.length === 1 ? '' : 's'} — post-modify workflow`;
  }

  return null;
}
