import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
import { join, relative } from 'node:path'
import { execFileSync } from 'node:child_process'
import { git } from './git.js'
import { writeManagedBlock, removeManagedBlock } from './harness.js'

// @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
// CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
// is either a no-op (git ignores only untracked paths) or would hide USER content. A git clean/smudge
// content filter keeps the two contents on their own sides of the index: the REPO stores the
// pristine host prose (clean strips our sentinel block on stage/diff), the WORKING TREE carries prose + block
// (smudge re-injects it on checkout). Everything the filter needs is PER-CLONE — `git config
// filter.spexcode.*` + a managed block in `.git/info/attributes` + two files under `<common>/spexcode/` —
// zero repo footprint. The sentinels are the load-bearing anchor: the invariant
// is clean(smudge(x)) == x (for text ending in one newline — see the shim), so `git status` stays clean.
// Planted only where mixed content EXISTS or is imminent (a tracked contract file, or an untracked one the
// user's prose entered — pre-armed so their eventual `git add` strips the block; a wholly-ours file needs
// only the exclude). materialize plants/refreshes/erases it per that live kind detection ([[residence]]
// / the forgetting law).

// the three field-sharpened edges this module owes ([[content-filter]]):
//   ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
//     missing — a bare missing filter command makes git spray "cannot fork" fatals on EVERY operation;
//   ② a changed contract does NOT propagate by itself (git re-smudges only on checkout) — materialize's
//     re-materialize writes the managed block straight into the working file (writeManagedBlock IS the re-smudge),
//     and this module refreshes the block file the shim reads so future checkouts agree;
//   ③ unplanting must strip the block from the WORKING FILES before the config goes away, or the block
//     residue surfaces as an uncommitted modification — the caller (dematerialize) removes the managed
//     blocks first and only then calls removeContractFilter.

function commonDirOf(proj: string): string {
  return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
}
const filterDir = (common: string) => join(common, 'spexcode')
const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
const attributesPath = (common: string) => join(common, 'info', 'attributes')

// the per-clone shim both filter directions run through. Pure shell/awk (no node boot on git's hot path),
// mirroring writeManagedBlock/removeManagedBlock's normalization exactly so the two writers agree:
//   clean : drop the sentinel block (+ the blank line smudge printed before it) → the pristine host prose.
//   smudge: clean first (defensive — a block already in the index can never double-inject), then append one
//           blank line + START + the contract-block file's content + END.
// Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
// file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
const SHIM = `#!/usr/bin/env bash
# spexcode contract filter (generated by spex materialize; see [[content-filter]]).
# clean(smudge(x)) == x: the repo keeps the pristine host prose, the working tree carries prose + block.
set -u
mode="\${1:?usage: contract-filter.sh smudge|clean}"
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
block="$here/contract-block.md"
strip() {
  awk 'BEGIN { n = 0 }
    { lines[n++] = $0 }
    END {
      s = -1; e = -1
      for (i = 0; i < n; i++) {
        if (lines[i] == "<!-- spexcode:start -->" && s < 0) s = i
        if (lines[i] == "<!-- spexcode:end -->" && s >= 0 && e < 0) e = i
      }
      if (s >= 0 && e >= s) {
        a = s; while (a > 0 && lines[a-1] == "") a--
        b = e; while (b + 1 < n && lines[b+1] == "") b++
        j = 0
        for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
        n = j
      }
      # NO leading-blank strip: dropping a..b (block + its surrounding blanks) can never CREATE a leading
      # blank, and a host file that BEGINS with blank lines must keep them — clean(smudge(x)) == x.
      for (i = 0; i < n; i++) print lines[i]
    }'
}
case "$mode" in
  clean) strip ;;
  smudge)
    if [ ! -r "$block" ]; then cat; exit 0; fi          # no block content → identity (graceful, never fatal)
    strip | awk -v b="$block" 'BEGIN { n = 0 }
      { lines[n++] = $0 }
      END {
        while (n > 0 && lines[n-1] == "") n--           # trim trailing blanks (writeManagedBlock parity)
        for (i = 0; i < n; i++) print lines[i]
        if (n > 0) print ""
        print "<!-- spexcode:start -->"
        while ((getline l < b) > 0) print l
        print "<!-- spexcode:end -->"
      }' ;;
  *) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
esac
`

// edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
// unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
  `sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`

// plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
// pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
// binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
// block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
export function plantContractFilter(proj: string, trackedFiles: string[], contract: string): void {
  const common = commonDirOf(proj)
  mkdirSync(filterDir(common), { recursive: true })
  writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`)   // edge ②: the shim's smudge source, refreshed with the materialize
  writeFileSync(shimPath(common), SHIM)
  chmodSync(shimPath(common), 0o755)
  git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
  git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
  // attribute patterns are checkout-relative, so one line serves the main checkout and every worktree.
  const entries = trackedFiles.map((f) => `/${relative(proj, f)} filter=spexcode`).sort().join('\n')
  mkdirSync(join(common, 'info'), { recursive: true })
  writeManagedBlock(attributesPath(common), entries, ['# ', ''])
  settleIndexStat(proj, trackedFiles)
}

// settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
// clean-filtered path by stat alone (worktree size ≠ blob size by design), and `git status` reports such an
// entry modified FOREVER without ever content-checking it (field-verified on git 2.43; `git diff` meanwhile
// runs the filter and shows nothing; even `update-index --really-refresh` leaves it). The block-strip side of
// a mode switch/backout leaves the same stale stat on the then-unfiltered path. `git add --renormalize`
// re-cleans the file and refreshes the cached stat — run ONLY when the (possibly filtered) worktree already
// EQUALS the index blob, so it is a pure stat refresh that can never stage a user's real unstaged edit (a
// genuine edit keeps its honest `M`). Exported for dematerialize (the unplant side); plant calls it below.
// Best-effort: an unsettled stat is cosmetic noise, never corruption.
export function settleIndexStat(proj: string, files: string[]): void {
  const env = { ...process.env }
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
  for (const f of files) {
    const rel = relative(proj, f)
    try {
      const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
      const filtered = execFileSync('git', ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
        { input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
      if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
    } catch { /* best-effort */ }
  }
}

// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
// (yatsu blobs), so only OUR two files go, never the dir.
export function removeContractFilter(proj: string): void {
  let common: string
  try { common = commonDirOf(proj) } catch { return }   // not a git repo → nothing was ever planted
  removeManagedBlock(attributesPath(common), ['# ', ''], true)
  for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
    try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
  }
  rmSync(shimPath(common), { force: true })
  rmSync(blockPath(common), { force: true })
}

// is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
export function contractFilterPlanted(proj: string): boolean {
  try { return git(['-C', proj, 'config', 'filter.spexcode.clean']).trim().length > 0 } catch { return false }
}
