#!/bin/bash

#
# File .......... swuk_repos_history
# Author ........ Steve Haywood
# Website ....... http://www.spacewire.co.uk
# Project ....... Common (SpaceWire UK Tutorial)
# Date .......... 09 Feb 2025
# Version ....... 1.0
# Description ...
#   Simple script that examines a sequential series of directories containing
# the history of a project(s) and publishes the results as a webpage.
#
# Internal :-
#
# Project files array string format, an associative array using the full path &
# filename as the key :-
#
# The last known hash of the file, wrapped with HTML table tags :-
# - Fixed @ 41x characters [40:00] :-
# - <td>000000 -- 32-digit hash -- 00000</td>
#
# The last known version of the file, wrapped with HTML table tags :-
# - Fixed @ 12x characters [52:41] :-
# - <td>ver</td>
#
# Current version of the file, basically $(git log --oneline $file | wc -l) :-
# - Variable length (number or dash), wrapped with HTML table tags :-
# - <td>v</td> or <td>-</td>
# - There will be $(git log --oneline | wc -l) number of these entries.
#

# Strict
set -euo pipefail

################################################################################
# Write out results table webpage.
# Arguments ...... None
# Return ......... None
# Shared (In) .... argv, exclude, fouthtml, hdr, lenhash, lentdclose, lentdopen,
#                  lenvers, optexclude, projfiles
# Shared (Out) ... argv
write_html()
{
  # Declare local variables
  local -a  keys  # Sorted keys
  local -i  tmplen=$(expr 2*${lentdopen}+2*${lentdclose}+${lenhash}+${lenvers})
  local     now=$(date +"%d %b %Y %H:%M:%S")  # Current date & time
  local -i  pos  # File position

cat << EOF > ${fouthtml}
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>SWUK Repository History</title>
<style>
body {
 font-family: "Muli", sans-serif;
}


table.repo {
  background-color: rgba(239,239,255,0.8);
  border: 1px solid #AAAAAA;
  border-collapse: collapse;
  padding: 2px 8px;
  overflow: hidden;
  z-index: 1;
}

table.repo td, th {
  border: 1px solid #AAAAAA;
  border-collapse: collapse;
  text-align: center;
  padding: 2px 8px;
  cursor: pointer;
  position: relative;
}

table.repo th {
  background-color: rgb(224,224,255);
}

table.repo th:hover {
  background-color: rgb(180,180,255);
  font-weight: bold;
}

table.repo td.chg {
  background-color: rgb(200,200,255);
}

table.repo td.chg:hover {
  background-color: rgb(180,180,255);
  font-weight: bold;
}

table.repo td.side:hover {
  background-color: rgb(224,224,255);
}

/* Row */
table.repo td.side:hover::before {
  background-color: rgb(180,180,255);
  content: '\00a0';
  height: 100%;
  left: -5000px;
  position: absolute;
  top: 0;
  width: 10000px;
  z-index: -1;
}

/* Column */
table.repo td.side:hover::after {
  background-color: rgb(239, 239, 255);
  content: '\00a0';
  height: 10000px;
  left: 0;
  position: absolute;
  top: -5000px;
  width: 100%;
  z-index: -1;
}

/* Row */
table.repo td:hover::before {
  background-color: rgb(180,180,255);
  content: '\00a0';
  height: 100%;
  left: -5000px;
  position: absolute;
  top: 0;
  width: 10000px;
  z-index: -1;
}

/* Column */
table.repo td:hover::after, table.repo th:hover::after {
  background-color: rgb(180,180,255);
  content: '\00a0';
  height: 10000px;
  left: 0;
  position: absolute;
  top: -5000px;
  width: 100%;
  z-index: -1;
}
</style>

</head>
<body>
EOF

  # Get sorted keys for project files list
  readarray -td '' keys < <(printf '%s\0' "${!projfiles[@]}" | sort -n -z)

  echo '<table class=repo id=table><tbody>' >> ${fouthtml}
  echo "<tr><th>No.</th><th style='text-align:left'>Files @ ${now}</th><th>I/E</th>${hdr}</tr>" >> ${fouthtml}

  # Iterate through project files list
  pos=1  # Reset file position
  for key in ${keys[@]}
  do
    if [[ ! " ${exclude[*]} " =~ " ${key} " ]]; then  # Inclusive file
      echo "<tr><td class=side style='text-align:left'>$(printf "%03d" ${pos})</td><td class=side style='text-align:left'>${key}<td class=side> Inc</td>${projfiles[${key}]:tmplen}</tr>" >> ${fouthtml}
      ((pos+=1))
    elif (($(option_set ${optexclude}))); then
      echo "<tr><td class=side style='text-align:left'>$(printf "%03d" ${pos})</td><td class=side style='text-align:left'><s>${key}</s><td class=side>Exc</td>${projfiles[${key}]:tmplen}</tr>" >> ${fouthtml}
      ((pos+=1))
    fi

  done
  echo '</tbody></table>' >> ${fouthtml}

cat << EOF >> ${fouthtml}
<script>
function compare(a, b) {
  return (a<b) ? -1 : (a>b) ? 1 : 0;
}

function sort(col) {
  let tbody = table.querySelector(\`tbody\`);
  let rows = Array.from(table.querySelectorAll(\`tr\`));
  rows = rows.slice(1);
  let qs = \`td:nth-child(\${col})\`;
  rows.sort( (r1,r2) => {
    let t1 = r1.querySelector(qs);
    let t2 = r2.querySelector(qs);
    return compare(t1.textContent.toLowerCase(), t2.textContent.toLowerCase());
  });
  rows.forEach(row => tbody.appendChild(row));
}

table.querySelectorAll(\`th\`).forEach((th, position) => {
  th.addEventListener(\`click\`, evt => sort(position+1));
});
</script>
EOF
  echo '</body></html>' >> ${fouthtml}
}


################################################################################
# Read the comment header block from a source file.
# Arguments ...... None
# Return ......... None
# Shared (In) .... file, header*, projdir, project, versi*, versitrunc*
# Shared (Out) ... header*, versi*, versitrunc*
#
# Note: The single line read used below is the stock way of doing this, where :-
# 1. IFS= (or IFS='') prevents leading/trailing whitespace from being trimmed.
# 2. -r prevents backslash escapes from being interpreted.
# 3. [[ -n ${line} ]] prevents the last line from being ignored if it doesn't end with a \n.
get_header()
{
  # Declare local variables
  local line         # Line string
  local -i pos=0     # Line position
  local -i type=0    # File type (0=Unknown, 1=Script)
  local -i stage=0   # Operation stage (0=Empty lines, 1=Header lines)
  local -i start=-1  # Start position of header
  local -i end=-1    # End position of header
  local -i valid=0   # Found a valid header (0=No, 1=Yes)

  # Initialise shared variables
  header=""          # Captured header block
  versi=""           # Captured version from header block
  versitrunc=""      # Captured version from header block (truncated)

  while IFS= read -r  line || [[ -n "${line}" ]]; do
    if [[ (${pos} -eq 0) && (${line:0:2} == "#!") ]]; then
      type=1
    elif [[ "${line}" =~ ^[[:space:]]*$ ]]; then
      [[ ${stage} -eq 1 ]] && break  # End of header
    else
      [[ ${stage} -eq 0 ]] && start=${pos}
      end=${pos}
      stage=1
      [[ "${line}" =~ ^.*[Ff][Ii][Ll][Ee]\ \.{3,}\ .+$ ]] && valid=1

      # Extract version number from the header
      local tmp=$(sed -n 's/^.*Version \.\{7\} \(.*\)$/\1/p' <<< "${line}")
      if [[ (${tmp} != "") && (${versi} == "") ]]; then
        versi=${tmp}
        local versplit=(${tmp//./ })
        [[ (${#versplit[@]} -eq 2) && (${versplit[1]} == "0") ]] &&
          versitrunc=${versplit[0]} || versitrunc=${tmp}
      fi

      header+="${line}&#10;"
    fi
    pos=$((pos+1))
    [[ ${pos} -eq 100 ]] && break
  done < "${project}/${projdir}/${file}"

  [[ ${valid} -eq 0 ]] && header="No header found!"

  # Swap out characters that cause HTML display issues
  header=${header//\'/&#39;}
}


################################################################################
# Process project directories & produce report.
# Arguments ...... None
# Return ......... None
# Shared (In) .... argv, exclude, foutfileset, fouthashset, fouthtml,
#                  optexclude, optheader, optlink, projdirs, project
# Shared (Out) ... argv, exclude, file, fouthtml, hdr, lenhash, lentdclose,
#                  lentdopen, lenvers, optexclude, projdir, project, projfiles,
#                  header*, versi*, versitrunc*
process_project()
{
  # Declare local constants
  local -r  tdopen="<td>"           # Str: Table data cell open tag
  local -r  tdclose="</td>"         # Str: Table data cell close tag
  local -ri lentdopen=${#tdopen}    # Int: Length of 'tdopen' string
  local -ri lentdclose=${#tdclose}  # Int: Length of 'tdclose' string
  local -ri lenhash=32              # Int: Length of 'vers' & 'oldvers' strings
  local -ri lenvers=3               # Int: Length of 'hash' & 'oldhash' strings

  # Declare local variables
  local     projdir                 # Str: Current subdirectory name
  local     projdirprev             # Str: Previous subdirectory name
  local -i  projdirnum=1            # Int: Current subdirectory number
  local -a  hashes=()               # Arr: List of hashes for changed files
  local -A  projfiles=()            # ARR: List of processed files (associative array)
  local -a  files                   # Arr: List of files in current subdirectory (array)
  local     file                    # Str: Current file
  local     vers                    # Str: Current file version (3-char string, 0 padded unsigned)
  local     oldvers                 # Str: Last known file version (3-char string, 0 padded unsigned)
  local     hash                    # Str: Current file hash (32-char string, hex)
  local     oldhash                 # Str: Last known file hash (32-char string, hex)
  local     hdr                     # Str: Table header cells
  local     header                  # Str: Captured header block
  local     versi                   # Str: Captured version from header block
  local     versitrunc              # Str: Captured version from header block (truncated)

  # Iterate through subdirectories
  for projdir in ${projdirs[@]}
  do
    echo Processing ... ${projdirnum} @ ${projdir}
    # Get all files in current subdirectory
    readarray -t files < <(find ${project}/${projdir} \( -type l -o -type f \) -not -path '*/*/.git/*' | sort | cut -d'/' -f3-)
    # Iterate through all files in subdirectory
    for file in ${files[@]}
    do
      # Get hash for current file
      hash=$(md5sum ${project}/${projdir}/${file} | cut -c -${lenhash})
      # Check file vs. master files list (to add)
      if [[ -v projfiles[${file}] ]]; then  # Existing file, in master files list, check against last known
        # Get last known hash & version
        oldhash=${projfiles[${file}]:lentdopen:lenhash}
        local -i tmppos=$(expr 2*${lentdopen}+${lentdclose}+${lenhash})
        oldvers=${projfiles[${file}]:tmppos:lenvers}
        # Check for hash difference
        if [[ ${hash} == ${oldhash} ]]; then  # Same file, identical contents
          # Carry forward version to indicate no change
          vers=${oldvers}
          # Replace duplicate file with link to its original
          (($(option_set ${optlink}))) &&
            ln -fs $(pwd)/${project}/${projdirprev}/${file} ${project}/${projdir}/${file}
          # Check if file is a link, if so indicate with an underline
          if [ -L ${project}/${projdir}/${file} ]; then
            # Add table data cell for the current file version
            projfiles[${file}]+="<td><u>$(expr ${vers} + 0)</u></td>"
          else
            # Add table data cell for the current file version
            projfiles[${file}]+="<td>$(expr ${vers} + 0)</td>"
          fi
        else  # Same file, different contents
          # Increment version to indicate a change
          vers=$(printf "%03d" $(expr ${oldvers} + 1))
          # Update hash & version (changes on hash difference)
          local -i tmplen=$(expr 2*${lentdopen}+2*${lentdclose}+${lenhash}+${lenvers})
          projfiles[${file}]="<td>${hash}</td><td>${vers}</td>${projfiles[${file}]:tmplen}"
          # Add table data cell for the current file version
          if (($(option_set ${optheader}))); then
            get_header
            if [[ ${versi} != "" ]]; then
              if [[ ${versitrunc} != "$(expr ${vers} + 0)" ]]; then
                projfiles[${file}]+="<td class=chg style='color:orange' title='${header}'> ${versi}</td>"
              else
                projfiles[${file}]+="<td class=chg title='${header}'> ${versi}</td>"
              fi
            else
              projfiles[${file}]+="<td class=chg title='${header}'> $(expr ${vers} + 0)</td>"
            fi
          else
            projfiles[${file}]+="<td class=chg> $(expr ${vers} + 0)</td>"
          fi
          # Add hash & filename to hash list
          hashes+=("${hash} - $(printf "%03d" ${projdirnum}) - ${file}")
        fi
      else  # New file, not in master files list, add file to list
        # Set version to 1
        vers="001"
        # Add hash & version for the newly discovered file
        projfiles[${file}]="<td>${hash}</td><td>${vers}</td>"
        # Fill in version backstory (empty table data cells)
        for (( i = 1; i < ${projdirnum}; i++ )); do
          projfiles[${file}]+="<td>•</td>"
        done
        # Add new table data cell for the newly discovered file
        if (($(option_set ${optheader}))); then
          get_header
          if [[ ${versi} != "" ]]; then
            if [[ ${versitrunc} != "$(expr ${vers} + 0)" ]]; then
              projfiles[${file}]+="<td class=chg style='color:red' title='${header}'> ${versi}</td>"
            else
              projfiles[${file}]+="<td class=chg title='${header}'> ${versi}</td>"
            fi
          else
            projfiles[${file}]+="<td class=chg title='${header}'> $(expr ${vers} + 0)</td>"
          fi
        else
          projfiles[${file}]+="<td class=chg> $(expr ${vers} + 0)</td>"
        fi
        # Add hash & filename to hash list
        hashes+=("${hash} - $(printf "%03d" ${projdirnum}) - ${file}")
      fi
    done

    # Check master files list vs. file (to remove)
    for key in ${!projfiles[@]}
    do
      if [[ ! " ${files[*]} " =~ " ${key} " ]]; then
        # Add new table cell (empty) for absent file
        projfiles[${key}]+="<td>•</td>"
      fi
    done

    # Add new table header cell (current subdirectory number)
    hdr+="<th>$(printf "%02d" ${projdirnum})</th>"

    # Exit early (testing)
 #   [[ ${projdirnum} -eq 9 ]] && break

    # Shift current subdirectory name
    projdirprev=${projdir}

    # Increment subdirectory directory number
    ((projdirnum+=1))
  done

  # Write out HTML report
  write_html

  # Write out hash list of new, changed & renamed files
  printf '%s\n' "${hashes[@]}" | sort -n > ${fouthashset}

  # Write out list of all discovered files
  printf '%s\n' "${!projfiles[@]}" | sort -n > ${foutfileset}

  # Summary
  echo -e "\nDiscovered ${#projfiles[@]} files & ${#hashes[@]} file changes.\n"
}


################################################################################
# Check if an argument option is set.
# Arguments ...... $1 ... Str: Option
# Return ......... Set (0=unset, 1=set)
# Shared (In) .... argv
# Shared (Out) ... None
option_set()
{
  [[ " ${argv[*]} " =~ " ${1} " ]] && echo 1 || echo 0
}


################################################################################
# Main function.
# Arguments ...... None
# Return ......... None
# Exit code ...... Status (0=success, 1=failure)
# Shared (In) .... argv
# Shared (Out) ... argv, exclude, foutfileset, fouthashset, fouthtml,
#                  optexclude, optheader, optlink, projdirs, project
main()
{
  # Declare local constants
  local -r  optexclude="--exclude"    # Str: Exclude option name
  local -r  optlink="--link"          # Str: Link option name
  local -r  optheader="--header"      # Str: Header option name
  local -r  opthelp="--help"          # Str: Help option name
  local -Ar options=(                 # ARR: Options & associated help information
    [${optexclude}]="Display excluded files in the history report."
    [${optlink}]="Replace files with symbolic links for duplicate files."
    [${optheader}]="Read headers & display them in the history report."
    [${opthelp}]="Display this help and exit."
  )
  local -ar optorder=(                # Arr: Help options display order
    ${optexclude}
    ${optlink}
    ${optheader}
    ${opthelp}
  )

  # Declare local variables
  local    arg                        # Str: Current argument from argv array
  local    project=""                 # Str: Project directory [arg] (mandatory)
  local    exfile=""                  # Str: Exclude file [arg] (optional)
  local    option                     # Str: Current option from optorder array

  # Display help information
  if (($(option_set ${opthelp}))); then
    echo "Usage: $(basename $0) PROJECT-DIRECTORY... [EXCLUDE-FILE]... [OPTION]..."
    echo "Scan the subdirectories in the PROJECT-DIRECTORY & generate a history report."
    echo
    for option in ${optorder[@]}
    do
      echo "      ${option} $(printf ' %.0s' {1..12} | head -c $((12-${#option}))) ${options[${option}]}"
    done
    echo
    exit 0
  fi

  # Get & check the arguments
  for arg in ${argv[@]}; do
    if [[ ${arg:0:2} == "--" ]]; then  # Option
      [[ ! -v options[${arg}] ]] && echo "Option (${arg}) is not recognised!" && exit 1
    elif [[ -z ${project} ]]; then  # Project directory
      project=${arg}
      [[ ! -d ${project} ]] && echo "Project directory (${arg}) does not exist!" && exit 1
    elif [[ -z ${exfile} ]]; then  # Exclude file
      exfile=${arg}
      [[ ! -f ${exfile} ]] && echo "Exclude file (${arg}) does not exist!" && exit 1
    else
      echo "Unexpected argument (${arg}) found!" && exit 1
    fi
  done

  # Further check the arguments
  [[ -z ${project} ]] && echo "No project directory specified!" && exit 1

  # Read list of excluded files
  local -a exclude=()  # Arr: List of excluded files
  [[ -n ${exfile} ]] && readarray -t exclude < ${exfile}

  # Get all subdirectories into an ordered array
  local -a projdirs=()  # Arr: List of project subdirectories (alphanumeric order)
  readarray -t projdirs < <(find ${project} -mindepth 1 -maxdepth 1 -type d | sort | cut -d'/' -f2-)
  [[ ${#projdirs[@]} -eq 0 ]] && echo "Project directory specified (${project}) does not contain any subdirectories!" && exit 1

  # Declare local constants
  local -r fouthtml="history.html"    # Str: Report HTML page
  local -r foutfileset="fileset.txt"  # Str: List of all discovered files
  local -r fouthashset="hashset.txt"  # Str: Hash list of new, changed & renamed files

  # Process the project subdirectories
  process_project
}


################################################################################
# Opening gambit.
# Arguments ...... None
# Return ......... None
# Exit code ...... Status (0=success, 1=failure)
# Shared (In) .... $#, $@
# Shared (Out) ... argc, argv
declare -ri argc=${#}    # Int: Get argument count
declare -ra argv=(${@})  # Arr: Get argument values (space-separated) into array
main
exit 0
