<?php
namespace app\adminVue\controller;

use think\facade\Db;

/** 内网点检参数：仅向既有四张基础表同步，不写点检结果及异常。 */
abstract class V2MachineSync
{
    const TABLES = ['devices'=>'me_machine_list','clauses'=>'me_check_list','regions'=>'team_base','models'=>'me_base'];
    const FIELDS = [
        'devices'=>['id','m_name','type','fixed_number','project','line','poid','change_type','is_check','is_alarm','is_care','is_release','update','owner','mark','status','release_date','qc','qc_update'],
        'clauses'=>['id','type','content','method','standard','num','freq','order','status','owner','update','markdown'],
        'regions'=>['id','mid','title','content','mark'],
        'models'=>['id','mid','title','content','mark','status'],
    ];
    public static function connection() { return V2InventoryData::connection(); }
    private static function q($name) { return V2InventoryData::quote($name); }
    private static function live($prefix) { $out=[];foreach(self::TABLES as $k=>$t)$out[$k]=$prefix.$t;return $out; }
    private static function fingerprint($pdo,$tables) { return V2InventoryData::fingerprint($pdo,$tables); }
    private static function directory() {
        $p=app()->getRuntimePath().'machine-sync/';if(!is_dir($p)&&!mkdir($p,0700,true)&&!is_dir($p))throw new \RuntimeException('无法创建同步目录');return $p;
    }
    private static function path($ticket) {
        if(!is_string($ticket)||!preg_match('/^[a-f0-9]{24}$/D',$ticket))throw new \InvalidArgumentException('同步票据无效');return self::directory().$ticket.'.json';
    }
    private static function save($meta) {
        $p=self::path($meta['ticket']);if(file_put_contents($p.'.tmp',json_encode($meta,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),LOCK_EX)===false||!rename($p.'.tmp',$p))throw new \RuntimeException('同步状态保存失败');
    }
    private static function read($ticket,$binding) {
        $m=json_decode(@file_get_contents(self::path($ticket)),true);list(, $prefix,$db)=self::connection();
        if(!$m||!hash_equals($m['binding'],$binding)||$m['database']!==$db||$m['prefix']!==$prefix||($m['state']!=='done'&&$m['expires']<time()))throw new \InvalidArgumentException('预览已过期或不属于当前会话，请重新上传');return $m;
    }
    public static function lock($prefix,$db,$work) {
        list($pdo)=self::connection();$key='machine-import:'.substr(hash('sha256',$db.$prefix),0,40);$q=$pdo->prepare('SELECT GET_LOCK(?,0)');$q->execute([$key]);
        if((int)$q->fetchColumn()!==1)throw new \InvalidArgumentException('其他同步操作正在进行，请稍后重试');
        try{return $work($pdo);}finally{$q=$pdo->prepare('SELECT RELEASE_LOCK(?)');$q->execute([$key]);}
    }
    private static function drop($pdo,$tables) { foreach($tables as $t)$pdo->exec('DROP TABLE IF EXISTS '.self::q($t)); }
    private static function cleanExpired($pdo,$prefix,$db) {
        foreach(glob(self::directory().'*.json') as $p){$m=json_decode(file_get_contents($p),true);if(!$m||$m['database']!==$db||$m['prefix']!==$prefix)continue;
            if($m['state']==='switching')throw new \InvalidArgumentException('上次同步结果尚待确认，请先重试原提交或由管理员核对备份');
            if($m['expires']>=time()||!in_array($m['state'],['preparing','ready'],true))continue;self::drop($pdo,$m['stages']);unlink($p);}
    }
    private static function text($v) { return str_replace(["\r\n","\r"],"\n",(string)$v); }
    private static function normalize($kind,$input,$schema,$old) {
        $out=[];
        foreach(self::FIELDS[$kind] as $f){
            $s=$schema[$f];$v=$input[$f]??'';
            // Older exports retain the existing frequency or use the column default.
            if($kind==='clauses'&&$f==='freq'&&!array_key_exists($f,$input))$v=$old[$f]??$s['Default']??1;
            $integer=preg_match('/^(tinyint|smallint|mediumint|int|bigint)/',$s['Type'],$type);
            if($v===''){
                if($old!==null&&array_key_exists($f,$old)&&($old[$f]===null||$old[$f]==='')){$out[$f]=$old[$f];continue;}
                if($s['Null']==='YES')$v=null;elseif($integer)throw new \InvalidArgumentException($f.'不能为空');
            }
            if($v!==null&&$integer){
                if(!preg_match('/^-?(0|[1-9][0-9]*)$/D',(string)$v))throw new \InvalidArgumentException($f.'必须为整数');
                $max=$type[1]==='tinyint'?127:($type[1]==='smallint'?32767:2147483647);$min=-$max-1;
                if(strpos($s['Type'],'unsigned')!==false){$min=0;$max=$max*2+1;}
                if((float)$v<$min||(float)$v>$max)throw new \InvalidArgumentException($f.'超出字段范围');$v=(int)$v;
            }elseif($v!==null){
                $v=self::text($v);if(preg_match('/varchar\((\d+)\)/',$s['Type'],$size)&&mb_strlen($v)>(int)$size[1])throw new \InvalidArgumentException($f.'超过'.$size[1].'字');
                if(preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/',$v))throw new \InvalidArgumentException($f.'含无效控制字符');
                if($old!==null&&isset($old[$f])&&self::text($old[$f])===$v)$v=$old[$f];
            }
            $out[$f]=$v;
        }
        if($out['id']<=0)throw new \InvalidArgumentException('id必须为正整数');
        foreach($out as $f=>$v)if(is_int($v)&&$v<0)throw new \InvalidArgumentException($f.'不能为负数');
        if(isset($out['status'])&&$out['status']!==1)throw new \InvalidArgumentException('只接受status=1的有效数据');
        if(in_array($kind,['regions','models'],true)&&!in_array($out['mid'],[1,2],true))throw new \InvalidArgumentException('只接受mid=1、2的参数');
        foreach($kind==='devices'?['is_check','is_alarm','is_care','is_release']:[] as $f)if(!in_array($out[$f],[null,0,1,2],true))throw new \InvalidArgumentException($f.'只能为空、0、1或2（1为是，0/2为否）');
        if($kind==='devices'&&!in_array($out['change_type'],[0,1,2,3],true))throw new \InvalidArgumentException('change_type无效');
        if($kind==='clauses'&&$out['num']<1)throw new \InvalidArgumentException('点检次数必须大于0');
        foreach($kind==='devices'?['type']:($kind==='clauses'?['content','type']:['title']) as $f)if($out[$f]===null||trim((string)$out[$f])===''||($f==='type'&&$out[$f]<=0))throw new \InvalidArgumentException($f.'不能为空');
        if($kind==='devices'&&trim((string)$out['m_name'])===''&&(!$old||trim((string)$old['m_name'])!==''))throw new \InvalidArgumentException('新增设备名称不能为空');
        return $out;
    }
    private static function changes($old,$row) {
        $out=[];foreach($row as $f=>$v)if($old===null||($v===null)!==($old[$f]===null)||self::text($v)!==self::text($old[$f]))$out[]=['field'=>$f,'before'=>$old[$f]??null,'after'=>$v];return $out;
    }
    private static function insert($pdo,$table,$rows) {
        if(!$rows)return;$cols=array_keys($rows[0]);$fields=implode(',',array_map([self::class,'q'],$cols));$updates=[];foreach($cols as $f)if($f!=='id')$updates[]=self::q($f).'=VALUES('.self::q($f).')';
        $stmt=$pdo->prepare('INSERT INTO '.self::q($table).' ('.$fields.') VALUES ('.implode(',',array_fill(0,count($cols),'?')).') ON DUPLICATE KEY UPDATE '.implode(',',$updates));foreach($rows as $r)$stmt->execute(array_values($r));
    }
    public static function prepare($path,$actor,$binding,$filename='') {
        list(,$prefix,$db)=self::connection();
        return self::lock($prefix,$db,function($pdo)use($path,$actor,$binding,$filename,$prefix,$db){
            self::cleanExpired($pdo,$prefix,$db);$live=self::live($prefix);$ticket=bin2hex(random_bytes(12));$stages=[];
            foreach($live as $k=>$t)$stages[$k]=$prefix.'v2mc_'.substr($ticket,0,12).'_'.$k;
            $m=['ticket'=>$ticket,'binding'=>$binding,'database'=>$db,'prefix'=>$prefix,'state'=>'preparing','expires'=>time()+3600,'createdAt'=>time(),'actorId'=>$actor['id'],'actorName'=>$actor['truename'],'filename'=>basename($filename),'stages'=>$stages,'rows'=>[],'errors'=>[],'warnings'=>[],'stats'=>[]];self::save($m);
            try{
                $sheets=V2InventoryFile::sheets($path);$expected=array_map(function($t){return 'oms_'.$t;},self::TABLES);
                if(array_diff(array_values($expected),array_keys($sheets)))throw new \InvalidArgumentException('必须包含oms_me_machine_list、oms_me_check_list、oms_team_base、oms_me_base四张工作表');
                foreach(array_diff(array_keys($sheets),array_values($expected)) as $extra)$m['warnings'][]=['kind'=>'models','id'=>'','line'=>null,'message'=>'额外工作表不参与同步：'.$extra];
                $strings=V2InventoryFile::strings($path);$m['fingerprint']=self::fingerprint($pdo,$live);$incoming=[];
                foreach($live as $kind=>$table){
                    $pdo->exec('CREATE TABLE '.self::q($stages[$kind]).' LIKE '.self::q($table));$pdo->exec('INSERT INTO '.self::q($stages[$kind]).' SELECT * FROM '.self::q($table));
                    $old=array_column($pdo->query('SELECT * FROM '.self::q($table))->fetchAll(\PDO::FETCH_ASSOC),null,'id');$schema=array_column($pdo->query('SHOW COLUMNS FROM '.self::q($table))->fetchAll(\PDO::FETCH_ASSOC),null,'Field');
                    if(array_diff(self::FIELDS[$kind],array_keys($schema)))throw new \InvalidArgumentException('目标表字段不完整：'.$table);
                    $nextId=0;
                    if($kind==='models'){
                        $nextId=$old?max(array_keys($old)):0;
                        $idColumn=null;foreach(V2InventoryFile::rows($path,$sheets[$expected[$kind]],$strings) as $scan){if($idColumn===null){$idColumn=array_search('id',array_map('trim',$scan),true);if($idColumn===false)break;continue;}if(isset($scan[$idColumn])&&ctype_digit((string)$scan[$idColumn]))$nextId=max($nextId,(int)$scan[$idColumn]);}
                    }
                    $headers=null;$seen=[];$normalized=[];$m['stats'][$kind]=['total'=>0,'new'=>0,'update'=>0,'disable'=>0,'same'=>0];
                    foreach(V2InventoryFile::rows($path,$sheets[$expected[$kind]],$strings) as $line=>$values){
                        if($headers===null){$headers=array_map('trim',$values);if(count($headers)!==count(array_unique($headers))||array_diff($kind==='clauses'?array_diff(self::FIELDS[$kind],['freq']):self::FIELDS[$kind],$headers)||array_diff($headers,self::FIELDS[$kind]))throw new \InvalidArgumentException($expected[$kind].'字段不匹配，请上传原始导出文件');continue;}
                        if(++$m['stats'][$kind]['total']>50000)throw new \InvalidArgumentException('单表最多50000条');$raw=[];foreach($headers as $col=>$f)$raw[$f]=$values[$col]??'';
                        try{
                            $id=$raw['id'];if(isset($seen[$id]))throw new \InvalidArgumentException('ID重复');$seen[$id]=true;$previous=$old[$id]??null;$row=self::normalize($kind,$raw,$schema,$previous);$id=$row['id'];
                            if($previous&&$kind==='models'&&!in_array((int)$previous['mid'],[1,2],true)){
                                // Notification/project settings are addressed by mid and mark, never by their row ID.
                                if(!in_array((int)$previous['mid'],[20,21],true))throw new \InvalidArgumentException('该ID属于其他参数类型mid='.$previous['mid'].'，禁止覆盖');
                                if(++$nextId>2147483647)throw new \InvalidArgumentException('参数ID已超出范围');
                                $stmt=$pdo->prepare('UPDATE '.self::q($stages[$kind]).' SET id=? WHERE id=?');$stmt->execute([$nextId,$id]);
                                $preserved=$previous;$preserved['id']=$nextId;$old[$nextId]=$preserved;unset($old[$id]);$previous=null;
                                if(count($m['warnings'])<300)$m['warnings'][]=['kind'=>$kind,'line'=>$line,'id'=>$id,'message'=>'保留原通知/项目负责人配置，其ID由'.$id.'移至'.$nextId.'；原ID用于文件中的分类/型号'];
                            }
                            if($previous&&$kind==='regions'&&(int)$previous['mid']!==$row['mid'])throw new \InvalidArgumentException('该ID在本地属于其他参数类型，禁止覆盖');
                            $diff=self::changes($previous,$row);$action=$previous?($diff?'update':'same'):'new';$m['stats'][$kind][$action]++;
                            $m['rows'][]=['kind'=>$kind,'id'=>$id,'line'=>$line,'title'=>$kind==='devices'?$row['m_name']:($kind==='clauses'?$row['content']:$row['title']),'action'=>$action,'changes'=>$diff,'row'=>$row];$normalized[]=$row;$incoming[$kind][$id]=$row;
                        }catch(\InvalidArgumentException $e){if(count($m['errors'])<100)$m['errors'][]=['kind'=>$kind,'line'=>$line,'id'=>$raw['id']??'','message'=>$e->getMessage()];}
                    }
                    if(!$headers||!$m['stats'][$kind]['total'])throw new \InvalidArgumentException($expected[$kind].'不能为空');self::insert($pdo,$stages[$kind],$normalized);
                    if(in_array($kind,['devices','clauses'],true))foreach($old as $id=>$r)if((int)$r['status']===1&&!isset($seen[$id])){
                        $row=$r;$row['status']=0;$m['stats'][$kind]['disable']++;$m['rows'][]=['kind'=>$kind,'id'=>(int)$id,'line'=>null,'title'=>$r['m_name']??$r['content'],'action'=>'disable','changes'=>[['field'=>'status','before'=>1,'after'=>0]],'row'=>$row];
                        $pdo->exec('UPDATE '.self::q($stages[$kind]).' SET status=0 WHERE id='.(int)$id);
                    }
                }
                self::validate($pdo,$stages,$incoming,$m,$prefix);
                if($m['errors']){self::drop($pdo,$stages);unlink(self::path($ticket));return ['valid'=>false,'errors'=>$m['errors'],'warnings'=>$m['warnings'],'stats'=>$m['stats']];}
                if(!hash_equals($m['fingerprint'],self::fingerprint($pdo,$live)))throw new \InvalidArgumentException('检查期间基础数据发生变化，请重新上传');
                $m['targetFingerprint']=self::fingerprint($pdo,$stages);$m['state']='ready';self::save($m);return self::summary($m);
            }catch(\Throwable $e){self::drop($pdo,$stages);@unlink(self::path($ticket));throw $e;}
        });
    }
    private static function validate($pdo,$stages,$incoming,&$m,$prefix) {
        $models=array_column($pdo->query('SELECT * FROM '.self::q($stages['models']))->fetchAll(\PDO::FETCH_ASSOC),null,'id');$regions=array_column($pdo->query('SELECT * FROM '.self::q($stages['regions']))->fetchAll(\PDO::FETCH_ASSOC),null,'id');$users=array_flip($pdo->query('SELECT id FROM '.self::q($prefix.'admin_user'))->fetchAll(\PDO::FETCH_COLUMN));$types=[];foreach($incoming['clauses']??[] as $r)$types[$r['type']]=true;
        foreach($m['rows'] as $r){if($r['action']==='disable')continue;$v=$r['row'];$kind=$r['kind'];$error=[];$warning=[];
            if(in_array($kind,['devices','clauses'],true)){
                $model=$models[$v['type']]??null;if(!$model||(int)$model['mid']!==2)$error[]='型号ID '.$v['type'].'不存在';elseif((int)$model['status']!==1)$warning[]='引用停用型号 '.$v['type'].'，保留原状态';
                if($v['owner']>0&&!isset($users[$v['owner']]))$error[]='人员ID '.$v['owner'].'不存在';
            }
            if($kind==='devices'){
                if(trim((string)$v['m_name'])==='')$warning[]='历史设备名称为空，按原值保留';
                foreach(['project'=>1,'line'=>2] as $f=>$mid)if($v[$f]>0&&(!isset($regions[$v[$f]])||(int)$regions[$v[$f]]['mid']!==$mid))$error[]=$f.' ID '.$v[$f].'不存在或类型错误';
                if($v['project']>0&&$v['line']>0&&isset($regions[$v['line']])&&(int)$regions[$v['line']]['mark']!==(int)$v['project'])$warning[]='项目与产线上级不一致，按原值保留';
                if((int)$v['is_check']===1&&!isset($types[$v['type']]))$warning[]='启用点检但未配置有效条款';
            }
            if($kind==='models'&&(int)$v['mid']===2){if(trim((string)$v['mark'])===''||$v['mark']==='0')$warning[]='历史型号未配置上级分类，按原值保留';elseif(!isset($models[$v['mark']])||(int)$models[$v['mark']]['mid']!==1)$error[]='上级设备分类不存在';}
            if($kind==='regions'&&(int)$v['mid']===2&&$v['mark']>0&&(!isset($regions[$v['mark']])||(int)$regions[$v['mark']]['mid']!==1))$error[]='上级项目不存在';
            foreach($error as $message)if(count($m['errors'])<100)$m['errors'][]=['kind'=>$kind,'id'=>$r['id'],'line'=>$r['line'],'message'=>$message];
            foreach($warning as $message)if(count($m['warnings'])<300)$m['warnings'][]=['kind'=>$kind,'id'=>$r['id'],'line'=>$r['line'],'message'=>$message];
        }
    }
    private static function summary($m) {return ['valid'=>true,'ticket'=>$m['ticket'],'stats'=>$m['stats'],'warnings'=>$m['warnings'],'errors'=>[],'state'=>$m['state']];}
    public static function preview($ticket,$binding,$kind,$action,$keyword,$page,$limit) {
        $m=self::read($ticket,$binding);$rows=array_values(array_filter($m['rows'],function($r)use($kind,$action,$keyword){return ($kind===''||$r['kind']===$kind)&&($action==='all'||($action==='diff'?$r['action']!=='same':$r['action']===$action))&&($keyword===''||mb_stripos($r['id'].' '.$r['title'].' '.json_encode($r['row'],JSON_UNESCAPED_UNICODE),$keyword)!==false);}));return ['list'=>array_slice($rows,($page-1)*$limit,$limit),'total'=>count($rows)];
    }
    public static function commit($ticket,$binding) {
        list(,$prefix,$db)=self::connection();return \app\mobile\service\CheckSyncGuard::run(true,function()use($ticket,$binding,$prefix,$db){return self::lock($prefix,$db,function($pdo)use($ticket,$binding,$prefix,$db){
            $m=self::read($ticket,$binding);$live=self::live($prefix);if($m['state']==='done'){\app\mobile\service\CheckCatalog::invalidate();return ['saved'=>true,'stats'=>$m['stats']];}
            if($m['state']==='switching'){
                $exists=0;foreach($m['stages'] as $t){$q=$pdo->prepare('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?');$q->execute([$db,$t]);$exists+=(int)$q->fetchColumn();}
                if(!$exists&&hash_equals($m['targetFingerprint'],self::fingerprint($pdo,$live))){$m['state']='done';$m['completedAt']=time();self::save($m);\app\mobile\service\CheckCatalog::invalidate();return ['saved'=>true,'stats'=>$m['stats']];}
                if($exists===4)$m['state']='ready';
            }
            if($m['state']!=='ready')throw new \InvalidArgumentException('同步结果待核对，请勿重复提交');
            // 引擎调整后，旧预览临时表不能换回去；重新上传会继承当前表的引擎。
            foreach($live as $kind=>$table){
                $q=$pdo->prepare('SHOW TABLE STATUS WHERE Name=?');$q->execute([$table]);$engine=$q->fetch(\PDO::FETCH_ASSOC)['Engine'];
                $q->execute([$m['stages'][$kind]]);$stage=$q->fetch(\PDO::FETCH_ASSOC);
                if(!$stage || strcasecmp($engine,$stage['Engine'])!==0)throw new \InvalidArgumentException('基础表存储引擎已调整，请重新上传参数文件生成预览');
            }
            // ponytail: a brief database-wide gate serializes saves only during the four-table switch.
            $gate='oms-check-sync:'.substr(hash('sha256',$db.$prefix),0,40);$q=$pdo->prepare('SELECT GET_LOCK(?,5)');$q->execute([$gate]);if((int)$q->fetchColumn()!==1)throw new \InvalidArgumentException('点检正在保存，请稍后重试');
            $locked=false;
            try{
                $locks=[];foreach(array_merge(array_values($live),array_values($m['stages'])) as $t)$locks[]=self::q($t).' WRITE';$pdo->exec('LOCK TABLES '.implode(',',$locks));$locked=true;
                if(!hash_equals($m['fingerprint'],self::fingerprint($pdo,$live))||!hash_equals($m['targetFingerprint'],self::fingerprint($pdo,$m['stages'])))throw new \InvalidArgumentException('基础数据或预览数据已变化，请重新上传');
                $parts=[];$backups=[];foreach($live as $k=>$t){$backups[$k]=$prefix.'v2mc_prev_'.substr($ticket,0,12).'_'.$k;$parts[]=self::q($t).' TO '.self::q($backups[$k]);$parts[]=self::q($m['stages'][$k]).' TO '.self::q($t);}
                $m['backups']=$backups;$m['state']='switching';self::save($m);
                // MySQL 5.7 forbids RENAME under LOCK TABLES. The application gate stays held;
                // verify the renamed originals as well to detect a legacy writer in this gap.
                $pdo->exec('UNLOCK TABLES');$locked=false;$pdo->exec('RENAME TABLE '.implode(',',$parts));
                if(!hash_equals($m['fingerprint'],self::fingerprint($pdo,$backups))){
                    if(hash_equals($m['targetFingerprint'],self::fingerprint($pdo,$live))){
                        $undo=[];foreach($live as $k=>$t){$undo[]=self::q($t).' TO '.self::q($m['stages'][$k]);$undo[]=self::q($backups[$k]).' TO '.self::q($t);}$pdo->exec('RENAME TABLE '.implode(',',$undo));$m['state']='ready';self::save($m);
                    }
                    throw new \InvalidArgumentException('切换期间其他系统修改了参数，同步未确认完成；请重新上传检查，必要时由管理员核对保留的备份');
                }
                $m['state']='done';$m['completedAt']=time();self::save($m);\app\mobile\service\CheckCatalog::invalidate();return ['saved'=>true,'stats'=>$m['stats']];
            }finally{if($locked)$pdo->exec('UNLOCK TABLES');$q=$pdo->prepare('SELECT RELEASE_LOCK(?)');$q->execute([$gate]);}
        });});
    }
    public static function discard($ticket,$binding) {
        list(,$prefix,$db)=self::connection();return self::lock($prefix,$db,function($pdo)use($ticket,$binding){$m=self::read($ticket,$binding);if($m['state']==='ready'){self::drop($pdo,$m['stages']);unlink(self::path($ticket));}return ['discarded'=>true];});
    }
    public static function latest() {
        list(,$prefix,$db)=self::connection();$last=null;foreach(glob(self::directory().'*.json') as $p){$m=json_decode(file_get_contents($p),true);if(!$m||$m['database']!==$db||$m['prefix']!==$prefix||$m['state']!=='done')continue;if(!$last||$m['completedAt']>$last['completedAt'])$last=['completedAt'=>$m['completedAt'],'actorName'=>$m['actorName'],'filename'=>$m['filename'],'stats'=>$m['stats']];}return $last;
    }
}
